[OAuth2.0] ScribeJAVA로 OAuth2.0 인증서버 구축하기 - 8. 프로세스 구현하기
⏰ 2021-10-24 (일) 23:58:10
개요
4장부터 7장까지 진행하며 각 플랫폼의 인증 모듈을 구현했다. 이 장에서는 모듈을 사용하는 주체인 프로세스를 구현한다.
프로세스는 HTTP 메소드에 따라 구분하여 관리한다.
구조
계정 관련 동작 이외엔 없으므로, 대분류는 account
하나 뿐이다.
작업에 필요한 HTTP 메소는 GET
, POST
, PUT
, DELETE
이므로, 아래와 같이 구분한다.
AccountGetProcess
- 계정 GET 프로세스 클래스AccountPostProcess
- 계정 POST 프로세스 클래스AccountPutProcess
- 계정 PUT 프로세스 클래스AccountDeleteProcess
- 계정 DELETE 프로세스 클래스
GET
메소드에 사용하는 로직은 AccountGetProcess
에 포함되는 식으로 구성한다.
프로세스 구현
Process 추상 클래스 구현
여러 프로세스에 공통 로직을 적용하기 위해, 모든 프로세스 객체에 상속할 Process 추상 클래스를 구현한다.
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
package global.module; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import oauth.account.module.AuthModule; import oauth.account.module.GithubAuthModule; import oauth.account.module.GoogleAuthModule; import oauth.account.module.KakaoAuthModule; import oauth.account.module.NaverAuthModule; /** * 프로세스 추상 클래스 * * @author RWB * @since 2021.09.30 Thu 01:14:25 */ abstract public class Process { protected HttpServletRequest request; protected HttpServletResponse response; /** * 생성자 메서드 * * @param request: [HttpServletRequest] HttpServletResponse 객체 * @param response: [HttpServletResponse] HttpServletResponse 객체 */ protected Process(HttpServletRequest request, HttpServletResponse response) { this.request = request; this.response = response; } /** * 인증 모듈 반환 메서드 * * @param platform: [String] 플랫폼 * * @return [AuthModule] AuthModule 객체 * * @throws NullPointerException 유효하지 않은 플랫폼 */ protected AuthModule getAuthModule(String platform) throws NullPointerException { return switch (platform) { case "naver" -> NaverAuthModule.getInstance(); case "google" -> GoogleAuthModule.getInstance(); case "kakao" -> KakaoAuthModule.getInstance(); case "github" -> GithubAuthModule.getInstance(); default -> throw new NullPointerException(Util.builder("'", platform, "' is invalid platform")); }; } }
서블릿 객체인 HttpServletRequest
, HttpServletResponse
에 쉽게 접근하기 위해 protected
접근 제어자로 각 지역변수를 선언한다.
생성자 사용 시 반드시 HttpServletRequest
, HttpServletResponse
를 인수로 주도록 강제한다.
이를 통해 Process
를 상속하는 모든 하위 프로세스 클래스는 반드시 서블릿 객체를 인수로 받아야하며, 프로세스 내부에서 request
, response
로 서블릿 객체에 접근할 수 있다.
getAuthModule
은 각 플랫폼 이름에 따라 해당하는 인스턴스를 반환하는 메서드다. 인증 모듈은 주로 프로세스에서 많이 사용하게 되므로, Process
에 선언하여 이를 상속하는 모든 프로세스 클래스가 해당 메서드에 접근할 수 있도록 구성한다.
이러한 구성으로 동일한 프로세스에서 플랫폼별로 AuthModule
객체를 호출하여 플랫폼별로 선언한 메서드를 사용할 수 있다.
GET 프로세스 구현
계정 프로세스 중 GET
에 해당하는 동작이 집합된 프로세스 클래스를 구현한다.
- 인증 URL 응답 반환 메서드
- 사용자 정보 응답 반환 메서드
GET
에 해당하는 동작은 위 두 메서드다. 단순히 데이터를 받아오는 작업들로 구성되어있다.
인증 URL 응답 반환 메서드
플랫폼 로그인을 위한 인증 URL을 반환하는 메서드다.
AuthModule
의 getAuthorizationUrl
메서드를 통해 URL를 얻고, 이 내용을 담아 응답 객체로 만들어 반환한다.
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
public Response getAuthorizationUrlResponse(String platform) { Response response; ResponseBean<String> responseBean = new ResponseBean<>(); // 인증 URL 응답 생성 시도 try { String state = UUID.randomUUID().toString(); request.getSession().setAttribute("state", state); AuthModule authModule = getAuthModule(platform); responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage(Util.builder(platform, " authrorization url response success")); responseBean.setBody(authModule.getAuthorizationUrl(state)); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build(); } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build(); } return response; }
동일한 세션인지 확인하기 위해 프로세스 수행 시 state
를 생성하여 getAuthorizationUrl
에 전달한다. 해당 메서드가 반환하는 URL에 전달한 state
가 URL 파라미터로 입력되어있을 것이다.
해당 state
를 세션 애트리뷰트에도 등록한다.
플랫폼 로그인은 여러 창을 거치기 때문에, 요청 하이재킹이 매우 쉽다. 이 과정에서 세션 정보가 손상되기 쉬우므로 state
를 통해 로그인 과정 전체가 동일한 세션에서 이루어지고 있는지 검증할 수 있다.
만약 URL의 state
와 세션의 state
가 일치하지 않거나, 세션 정보가 아예 없다면 정상적인 로그인 절차가 아니라고 판단할 수 있다.
추후 이 세션값은 Access Token을 받아 로그인 작업을 수행할 때 사용한다.
사용자 정보 응답 반환 메서드
Access Token을 통해 사용자 응답을 받는 메서드다.
AuthModule
의 getUserInfoBean
메서드를 통해 UserInfoBean
객체를 얻고, 이 내용을 담아 응답 객체로 만들어 반환한다.
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
public Response getUserInfoResponse(String accessCookie) { Response response; ResponseBean<UserInfoBean> responseBean = new ResponseBean<>(); // 사용자 정보 응답 생성 시도 try { Jws<Claims> jws = JwtModule.openJwt(accessCookie); String accessToken = jws.getBody().get("access", String.class); String platform = jws.getBody().get("platform", String.class); AuthModule authModule = getAuthModule(platform); com.github.scribejava.core.model.Response userInfoResponse = authModule.getUserInfo(accessToken); // 응답이 정상적이지 않을 경우 if (userInfoResponse.getCode() != 200) { throw new OAuthResponseException(userInfoResponse); } responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage("user info response success"); responseBean.setBody(authModule.getUserInfoBean(userInfoResponse.getBody())); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build(); } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build(); } return response; }
추후 설명하겠지만, 로그인 시 Access Token과 Refresh Token을 각각 플랫폼과 함께 JWT로 생성하여 access, refresh 쿠키로 저장한다.
각 JWT 쿠키에 플랫폼 정보가 있으므로, access 쿠키만 있어도 Access Token와 그 플랫폼을 찾을 수 있다.
전체 코드
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138
package oauth.account.process; import com.github.scribejava.core.model.OAuthResponseException; import global.bean.ResponseBean; import global.module.JwtModule; import global.module.Process; import global.module.Util; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import oauth.account.bean.UserInfoBean; import oauth.account.module.AuthModule; import java.util.UUID; /** * 계정 GET 프로세스 클래스 * * @author RWB * @since 2021.09.30 Thu 21:00:48 */ public class AccountGetProcess extends Process { /** * 생성자 메서드 * * @param request: [HttpServletRequest] HttpServletRequest 객체 * @param response: [HttpServletResponse] HttpServletResponse 객체 */ public AccountGetProcess(HttpServletRequest request, HttpServletResponse response) { super(request, response); } /** * 인증 URL 응답 반환 메서드 * * @param platform: [String] 플랫폼 * * @return [Response] 응답 객체 */ public Response getAuthorizationUrlResponse(String platform) { Response response; ResponseBean<String> responseBean = new ResponseBean<>(); // 인증 URL 응답 생성 시도 try { String state = UUID.randomUUID().toString(); request.getSession().setAttribute("state", state); AuthModule authModule = getAuthModule(platform); responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage(Util.builder(platform, " authrorization url response success")); responseBean.setBody(authModule.getAuthorizationUrl(state)); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build(); } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build(); } return response; } /** * 사용자 정보 응답 반환 메서드 * * @param accessCookie: [String] 접근 토큰 쿠키 * * @return [Response] 응답 객체 */ public Response getUserInfoResponse(String accessCookie) { Response response; ResponseBean<UserInfoBean> responseBean = new ResponseBean<>(); // 사용자 정보 응답 생성 시도 try { Jws<Claims> jws = JwtModule.openJwt(accessCookie); String accessToken = jws.getBody().get("access", String.class); String platform = jws.getBody().get("platform", String.class); AuthModule authModule = getAuthModule(platform); com.github.scribejava.core.model.Response userInfoResponse = authModule.getUserInfo(accessToken); // 응답이 정상적이지 않을 경우 if (userInfoResponse.getCode() != 200) { throw new OAuthResponseException(userInfoResponse); } responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage("user info response success"); responseBean.setBody(authModule.getUserInfoBean(userInfoResponse.getBody())); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build(); } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build(); } return response; } }
POST 프로세스 구현
계정 프로세스 중 POST
에 해당하는 동작이 집합된 프로세스 클래스를 구현한다.
- 로그인 응답 반환 메서드
- 자동 로그인 응답 반환 메서드
- 로그아웃 응답 반환 메서드
POST
에 해당하는 동작은 위 두 메서드다. 주로 로그인/로그아웃 작업으로 구성되어있다.
로그인 응답 반환 메서드
플랫폼 로그인 이후 발급되는 code
를 통해 Access Token으로 교환하여 로그인을 수행하는 메서드다.
AuthModule
의 getAccessToken
메서드를 통해 OAuth2AccessToken
객체를 반환받아 Access Token, Refresh Token을 추출한다.
이 토큰들을 JWT 쿠키로 만들어 로그인 절차를 수행한다.
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
public Response postLoginResponse(String platform, String code, String state) { Response response; ResponseBean<String> responseBean = new ResponseBean<>(); HttpSession session = request.getSession(); // 로그인 응답 생성 시도 try { Object sessionState = Objects.requireNonNull(session.getAttribute("state")); // 고유 상태값이 일치하지 않을 경우 if (!state.equals(sessionState)) { throw new BadAttributeValueExpException("state is mismatched"); } AuthModule authModule = getAuthModule(platform); OAuth2AccessToken oAuth2AccessToken = authModule.getAccessToken(code); String accessToken = oAuth2AccessToken.getAccessToken(); String refreshToken = oAuth2AccessToken.getRefreshToken(); HashMap<String, Object> accessMap = new HashMap<>(); accessMap.put("access", accessToken); accessMap.put("platform", platform); HashMap<String, Object> refreshMap = new HashMap<>(); refreshMap.put("refresh", refreshToken); refreshMap.put("platform", platform); String accessJwt = JwtModule.generateJwt(state, accessMap); String refreshJwt = JwtModule.generateJwt(state, refreshMap); NewCookie accessCookie = new NewCookie("access", accessJwt, "/oauth2", ".itcode.dev", "access token", -1, true, true); NewCookie refreshCookie = new NewCookie("refresh", refreshJwt, "/oauth2", ".itcode.dev", "refresh token", refreshToken == null ? 0 : 86400 * 7 + 3600 * 9, true, true); responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage("authorized success"); responseBean.setBody(null); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).cookie(accessCookie, refreshCookie).build(); } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build(); } // 시도 후 finally { session.invalidate(); } return response; }
AccountGetProcess
의 getAuthorizationUrlResponse
동작 중 세션 애트리뷰트에 state
를 입력했었는데, 여기서 그 세션값을 통해 검증을 수행한다.
URL을 통해 인수로 받은 state
와 세션의 state
를 추출하여 비교하고, 동일하지 않을 경우 예외를 발생시킨다. 중간에 URL을 탈취해서 전혀 다른 code
를 삽입하여 요청을 보내도 이를 방지할 수 있는 보안책인 셈이다.
Access Token과 Refresh Token을 전달받아 JWT 쿠키로 만든다.
- Access Token JWT
JSON
1 2 3 4 5 6 7 8 9 10 11
{ "iss": "oauth2", "sub": "auth", "aud": "c9159786-40bf-4cf2-8c93-f683d1070137", "access": "{ACCESS_TOKEN}", "platform": "naver", "exp": 1634986011, "nbf": 1634982411, "iat": 1634982411, "jti": "c9159786-40bf-4cf2-8c93-f683d1070137" }
- Refresh Token JWT
JSON
1 2 3 4 5 6 7 8 9 10 11
{ "iss": "oauth2", "sub": "auth", "aud": "c9159786-40bf-4cf2-8c93-f683d1070137", "refresh": "{REFRESH_TOKEN}", "platform": "naver", "exp": 1634986011, "nbf": 1634982411, "iat": 1634982411, "jti": "c9159786-40bf-4cf2-8c93-f683d1070137" }
JWT의 내용은 위와 같다. 쿠키에 해당 JWT를 담아 생성한다. access 쿠키는 세션 쿠키로 생성하여 브라우저 종료 시 즉시 쿠키가 즉시 소멸되도록 구성하고, refresh 쿠키는 어느 정도 보관기간을 두어 추후 다시 사용할 수 있도록 구성한다.
쿠키 도메인은 .itcode.dev
로 지정되어있는데, 그 이유는 프론트엔드와 백엔드가 전혀 다른 환경에서 동작하기 때문이다.
- Frontend -
project.itcode.dev
- Backend -
api.itcode.dev
브라우저의 보안정책으로 다른 도메인에 쿠키를 생성할 수 없다. 때문에 .itcode.dev
로 지정하여 모든 서브 도메인에 적용하도록 구성했다.
만약 도메인을 지정하지 않으면 자동으로 api.itcode.dev
를 대상으로 쿠키를 발급한다. 따라서 project.itcode.dev
도메인 서비스에서는 쿠키에 접근할 수 없다.
자동 로그인 응답 반환 메서드
만약 이전에 로그인을 수행한 이력이 있어, access, refresh 쿠키를 이미 가지고 있을 경우 이를 활용하여 자동 로그인을 수행하는 메서드다.
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88
public Response postAutoLoginResponse(String accessCookie, String refreshCookie) { Response response; ResponseBean<String> responseBean = new ResponseBean<>(); // 자동 로그인 시도 try { // 접근 토큰 쿠키가 있을 경우 if (accessCookie != null) { responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage("auto authorized success"); responseBean.setBody(null); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build(); } // 리프레쉬 토큰 쿠키가 없을 경우 else if (refreshCookie == null) { responseBean.setFlag(false); responseBean.setTitle("fail"); responseBean.setMessage("refresh token is empty"); responseBean.setBody(null); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build(); } // 리프레쉬 토큰 쿠키가 있을 경우 else { Jws<Claims> refreshJws = JwtModule.openJwt(refreshCookie); String refreshToken = refreshJws.getBody().get("refresh", String.class); String platform = refreshJws.getBody().get("platform", String.class); AuthModule authModule = getAuthModule(platform); OAuth2AccessToken oAuth2AccessToken = authModule.getRefreshAccessToken(refreshToken); String accessToken = oAuth2AccessToken.getAccessToken(); HashMap<String, Object> accessMap = new HashMap<>(); accessMap.put("access", accessToken); accessMap.put("platform", platform); HashMap<String, Object> refreshMap = new HashMap<>(); refreshMap.put("refresh", refreshToken); refreshMap.put("platform", platform); String uuid = UUID.randomUUID().toString(); String accessJwt = JwtModule.generateJwt(uuid, accessMap); String refreshJwt = JwtModule.generateJwt(uuid, refreshMap); NewCookie newAccessCookie = new NewCookie("access", accessJwt, "/oauth2", ".itcode.dev", "access token", -1, true, true); NewCookie newRefreshCookie = new NewCookie("refresh", refreshJwt, "/oauth2", ".itcode.dev", "refresh token", 86400 * 7 + 3600 * 9, true, true); responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage("auto authorized success"); responseBean.setBody(null); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).cookie(newAccessCookie, newRefreshCookie).build(); } } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); NewCookie newAccessCookie = new NewCookie("access", null, "/oauth2", ".itcode.dev", "access token", 0, true, true); NewCookie newRefreshCookie = new NewCookie("refresh", null, "/oauth2", ".itcode.dev", "refresh token", 0, true, true); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).cookie(newAccessCookie, newRefreshCookie).build(); } return response; }
access 쿠키가 이미 있을 경우, 이미 인증 정보가 있기 때문에 별다른 동작을 취하지 않고 넘어간다.
만약 access 쿠키는 없고 refresh 쿠키만 있다면, 이를 통해 Access Token을 재발급받아 인증 정보를 갱신하여 자동으로 로그인을 수행한다.
로그인 로직 자체는 기존 로그인 메서드와 동일하며, Access Token만 Refresh Token을 통해 갱신하여 사용한다.
로그아웃 응답 반환 메서드
인증 정보를 제거하여 로그아웃을 수행하는 메서드.
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
public Response postLogoutResponse() { Response response; ResponseBean<String> responseBean = new ResponseBean<>(); // 로그아웃 응답 생성 시도 try { NewCookie accessCookie = new NewCookie("access", null, "/oauth2", ".itcode.dev", "access token", 0, true, true); NewCookie refreshCookie = new NewCookie("refresh", null, "/oauth2", ".itcode.dev", "refresh token", 0, true, true); responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage("logout success"); responseBean.setBody(null); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).cookie(accessCookie, refreshCookie).build(); } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build(); } return response; }
인증정보는 쿠키 기반이다. 서버에서 쿠키 생성 시 보안을 위해 HttpOnly
옵션을 활성화했기 때문에 JavaScript에선 access, refresh 쿠키를 다룰 수 없다.
서버에서 쿠키 만료시간을 0으로 덮어씌워 인증쿠키를 제거한다.
전체 코드
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257
package oauth.account.process; import com.github.scribejava.core.model.OAuth2AccessToken; import global.bean.ResponseBean; import global.module.JwtModule; import global.module.Process; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.NewCookie; import jakarta.ws.rs.core.Response; import oauth.account.module.AuthModule; import javax.management.BadAttributeValueExpException; import java.util.HashMap; import java.util.Objects; import java.util.UUID; /** * 계정 POST 프로세스 클래스 * * @author RWB * @since 2021.10.02 Sat 00:53:52 */ public class AccountPostProcess extends Process { /** * 생성자 메서드 * * @param request: [HttpServletRequest] HttpServletRequest 객체 * @param response: [HttpServletResponse] HttpServletResponse 객체 */ public AccountPostProcess(HttpServletRequest request, HttpServletResponse response) { super(request, response); } /** * 로그인 응답 반환 메서드 * * @param platform: [String] 플랫폼 * @param code: [String] 인증 코드 * @param state: [String] 고유 상태값 * * @return [Response] 응답 객체 */ public Response postLoginResponse(String platform, String code, String state) { Response response; ResponseBean<String> responseBean = new ResponseBean<>(); HttpSession session = request.getSession(); // 로그인 응답 생성 시도 try { Object sessionState = Objects.requireNonNull(session.getAttribute("state")); // 고유 상태값이 일치하지 않을 경우 if (!state.equals(sessionState)) { throw new BadAttributeValueExpException("state is mismatched"); } AuthModule authModule = getAuthModule(platform); OAuth2AccessToken oAuth2AccessToken = authModule.getAccessToken(code); String accessToken = oAuth2AccessToken.getAccessToken(); String refreshToken = oAuth2AccessToken.getRefreshToken(); HashMap<String, Object> accessMap = new HashMap<>(); accessMap.put("access", accessToken); accessMap.put("platform", platform); HashMap<String, Object> refreshMap = new HashMap<>(); refreshMap.put("refresh", refreshToken); refreshMap.put("platform", platform); String accessJwt = JwtModule.generateJwt(state, accessMap); String refreshJwt = JwtModule.generateJwt(state, refreshMap); NewCookie accessCookie = new NewCookie("access", accessJwt, "/oauth2", ".itcode.dev", "access token", -1, true, true); NewCookie refreshCookie = new NewCookie("refresh", refreshJwt, "/oauth2", ".itcode.dev", "refresh token", refreshToken == null ? 0 : 86400 * 7 + 3600 * 9, true, true); responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage("authorized success"); responseBean.setBody(null); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).cookie(accessCookie, refreshCookie).build(); } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build(); } // 시도 후 finally { session.invalidate(); } return response; } /** * 자동 로그인 응답 반환 메서드 * * @param accessCookie: [String] 접근 토큰 쿠키 * @param refreshCookie: [String] 리프레쉬 토큰 쿠키 * * @return [Response] 응답 객체 */ public Response postAutoLoginResponse(String accessCookie, String refreshCookie) { Response response; ResponseBean<String> responseBean = new ResponseBean<>(); // 자동 로그인 시도 try { // 접근 토큰 쿠키가 있을 경우 if (accessCookie != null) { responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage("auto authorized success"); responseBean.setBody(null); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build(); } // 리프레쉬 토큰 쿠키가 없을 경우 else if (refreshCookie == null) { responseBean.setFlag(false); responseBean.setTitle("fail"); responseBean.setMessage("refresh token is empty"); responseBean.setBody(null); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build(); } // 리프레쉬 토큰 쿠키가 있을 경우 else { Jws<Claims> refreshJws = JwtModule.openJwt(refreshCookie); String refreshToken = refreshJws.getBody().get("refresh", String.class); String platform = refreshJws.getBody().get("platform", String.class); AuthModule authModule = getAuthModule(platform); OAuth2AccessToken oAuth2AccessToken = authModule.getRefreshAccessToken(refreshToken); String accessToken = oAuth2AccessToken.getAccessToken(); HashMap<String, Object> accessMap = new HashMap<>(); accessMap.put("access", accessToken); accessMap.put("platform", platform); HashMap<String, Object> refreshMap = new HashMap<>(); refreshMap.put("refresh", refreshToken); refreshMap.put("platform", platform); String uuid = UUID.randomUUID().toString(); String accessJwt = JwtModule.generateJwt(uuid, accessMap); String refreshJwt = JwtModule.generateJwt(uuid, refreshMap); NewCookie newAccessCookie = new NewCookie("access", accessJwt, "/oauth2", ".itcode.dev", "access token", -1, true, true); NewCookie newRefreshCookie = new NewCookie("refresh", refreshJwt, "/oauth2", ".itcode.dev", "refresh token", 86400 * 7 + 3600 * 9, true, true); responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage("auto authorized success"); responseBean.setBody(null); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).cookie(newAccessCookie, newRefreshCookie).build(); } } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); NewCookie newAccessCookie = new NewCookie("access", null, "/oauth2", ".itcode.dev", "access token", 0, true, true); NewCookie newRefreshCookie = new NewCookie("refresh", null, "/oauth2", ".itcode.dev", "refresh token", 0, true, true); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).cookie(newAccessCookie, newRefreshCookie).build(); } return response; } /** * 로그아웃 응답 반환 메서드 * * @return [Response] 응답 객체 */ public Response postLogoutResponse() { Response response; ResponseBean<String> responseBean = new ResponseBean<>(); // 로그아웃 응답 생성 시도 try { NewCookie accessCookie = new NewCookie("access", null, "/oauth2", ".itcode.dev", "access token", 0, true, true); NewCookie refreshCookie = new NewCookie("refresh", null, "/oauth2", ".itcode.dev", "refresh token", 0, true, true); responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage("logout success"); responseBean.setBody(null); response = Response.ok(responseBean, MediaType.APPLICATION_JSON).cookie(accessCookie, refreshCookie).build(); } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build(); } return response; } }
PUT 프로세스 구현
계정 프로세스 중 PUT
에 해당하는 동작이 집합된 프로세스를 구현한다.
- 정보 제공 동의 갱신 URL 응답 반환 메서드
PUT
에 해당하는 메서드는 하나다. 데이터를 수정하는 작업들로 구성되어있다.
정보 제공 동의 갱신 URL 응답 반환 메서드
정보 제공 동의를 새로 갱신하는 URL을 반환하는 메서드다.
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
public Response putUpdateAuthorizationUrl(String accessCookie) { Response response; ResponseBean<String> responseBean = new ResponseBean<>(); // 정보 제공 동의 갱신 URL 응답 생성 시도 try { String state = UUID.randomUUID().toString(); Jws<Claims> jws = JwtModule.openJwt(accessCookie); String platform = jws.getBody().get("platform", String.class); AuthModule authModule = getAuthModule(platform); String url = authModule.getUpdateAuthorizationUrl(state); // URL이 null일 경우 if (url == null) { responseBean.setFlag(false); responseBean.setTitle("skipped"); responseBean.setMessage(Util.builder(platform, " doesn't need that service")); responseBean.setBody(null); } // URL이 유효할 경우 else { request.getSession().setAttribute("state", state); responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage(Util.builder(platform, " reauthrorization url response success")); responseBean.setBody(url); } response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build(); } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build(); } return response; }
첫 로그인 시, 서비스가 요구하는 정보에 대해 동의/거부하는 절차를 거친다. 이후 유저 정보 요청 시, 이 정보 제공 동의 여부에 의거하여 동의한 정보만을 제공한다.
만약 서비스 운영 도중 요구 정보가 변경되어 추가적인 정보가 필요하다면, 정보 제공 동의를 갱신할 필요가 있다.
서비스는 반환받은 URL로 리다이렉션하여 동의를 갱신한 뒤 갱신된 정보가 반영된 code
를 반환한다. 이후의 과정은 로그인과 동일한 과정을 거친다.
즉, 정보 제공 동의는 새로운 정보를 갱신하여 로그인을 다시 수행하는 것과 동일하다.
전체 코드
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
package oauth.account.process; import global.bean.ResponseBean; import global.module.JwtModule; import global.module.Process; import global.module.Util; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import oauth.account.module.AuthModule; import java.util.UUID; /** * 계정 PUT 프로세스 클래스 * * @author RWB * @since 2021.10.19 Tue 21:56:32 */ public class AccountPutProcess extends Process { /** * 생성자 메서드 * * @param request: [HttpServletRequest] HttpServletRequest 객체 * @param response: [HttpServletResponse] HttpServletResponse 객체 */ public AccountPutProcess(HttpServletRequest request, HttpServletResponse response) { super(request, response); } /** * 정보 제공 동의 갱신 URL 응답 반환 메서드 * * @param accessCookie: [String] 접근 토큰 쿠키 * * @return [Response] 응답 객체 */ public Response putUpdateAuthorizationUrl(String accessCookie) { Response response; ResponseBean<String> responseBean = new ResponseBean<>(); // 정보 제공 동의 갱신 URL 응답 생성 시도 try { String state = UUID.randomUUID().toString(); Jws<Claims> jws = JwtModule.openJwt(accessCookie); String platform = jws.getBody().get("platform", String.class); AuthModule authModule = getAuthModule(platform); String url = authModule.getUpdateAuthorizationUrl(state); // URL이 null일 경우 if (url == null) { responseBean.setFlag(false); responseBean.setTitle("skipped"); responseBean.setMessage(Util.builder(platform, " doesn't need that service")); responseBean.setBody(null); } // URL이 유효할 경우 else { request.getSession().setAttribute("state", state); responseBean.setFlag(true); responseBean.setTitle("success"); responseBean.setMessage(Util.builder(platform, " reauthrorization url response success")); responseBean.setBody(url); } response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build(); } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build(); } return response; } }
DELETE 프로세스 구현
계정 프로세스 중 DELETE
에 해당하는 동작이 집합된 프로세스를 구현한다.
- 연동 해제 응답 반환 메서드
DELETE
에 해당하는 메서드는 하나다. 주로 데이터를 삭제하는 작업들로 구성되어있다.
연동 해제 응답 반환 메서드
플랫폼과의 연동을 완전히 해제하고 로그아웃을 수행하는 메서드다.
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
public Response deleteInfoResponse(String accessCookie) { Response response; ResponseBean<String> responseBean = new ResponseBean<>(); // 연동 해제 응답 생성 시도 try { Jws<Claims> jws = JwtModule.openJwt(accessCookie); String accessToken = jws.getBody().get("access", String.class); String platform = jws.getBody().get("platform", String.class); AuthModule authModule = getAuthModule(platform); // 연동 해제에 성공할 경우 if (authModule.deleteInfo(accessToken)) { response = new AccountPostProcess(request, this.response).postLogoutResponse(); } // 아닐 경우 else { throw new RequestAuthenticationException("revoke fail"); } } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build(); } return response; }
플랫폼과의 연동을 해제하면 발급받았던 Access Token과 Refresh Token이 만료되어 더 이상 제 기능을 할 수 없게 된다.
보통 회원탈퇴 시 이루어지는 과정이지만, 이 프로젝트는 별도의 회원가입이랄게 따로 없으므로, 자동으로 로그아웃을 시키는 것으로 끝난다.
추후 재로그인 시 처음 로그인하는 것과 동일한 과정을 거치게 된다.
전체 코드
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
package oauth.account.process; import global.bean.ResponseBean; import global.module.JwtModule; import global.module.Process; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import oauth.account.module.AuthModule; import org.glassfish.jersey.client.authentication.RequestAuthenticationException; /** * 계정 DELETE 프로세스 클래스 * * @author RWB * @since 2021.10.02 Sat 00:53:52 */ public class AccountDeleteProcess extends Process { /** * 생성자 메서드 * * @param request: [HttpServletRequest] HttpServletRequest 객체 * @param response: [HttpServletResponse] HttpServletResponse 객체 */ public AccountDeleteProcess(HttpServletRequest request, HttpServletResponse response) { super(request, response); } /** * 연동 해제 응답 반환 메서드 * * @param accessCookie: [String] 접근 토큰 쿠키 * * @return [Response] 응답 객체 */ public Response deleteInfoResponse(String accessCookie) { Response response; ResponseBean<String> responseBean = new ResponseBean<>(); // 연동 해제 응답 생성 시도 try { Jws<Claims> jws = JwtModule.openJwt(accessCookie); String accessToken = jws.getBody().get("access", String.class); String platform = jws.getBody().get("platform", String.class); AuthModule authModule = getAuthModule(platform); // 연동 해제에 성공할 경우 if (authModule.deleteInfo(accessToken)) { response = new AccountPostProcess(request, this.response).postLogoutResponse(); } // 아닐 경우 else { throw new RequestAuthenticationException("revoke fail"); } } // 예외 catch (Exception e) { e.printStackTrace(); responseBean.setFlag(false); responseBean.setTitle(e.getClass().getSimpleName()); responseBean.setMessage(e.getMessage()); responseBean.setBody(null); response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build(); } return response; } }
정리
이로써 프로젝트 구현을 완료했다. NaverAuthModule
, GoogleAuthModule
같은 각기 다른 인증모듈을 AuthModule
이라는 상위 객체로 반환받은 덕분에 복잡한 분기나 중복 코드를 막을 수 있었다.
파이프라인이 나눠지는 순간, 이와 연결된 하위 파이프라인까지 강제로 분리되는 경향이 있다. 가장 밑단인 모듈을 적절히 설계한 덕분에, 그 상위 파이프라인들은 하나로 관리할 수 있음을 확인할 수 있다.
다음 장에서는 Jersey를 통한 컨트롤러 구성 방법에 대해 다룬다.
🏷️ 태그
읽어주셔서 고마워요!
도움이 되셨다면, 공감이나 댓글을 달아주시는 건 어떤가요?
블로그 운영에 큰 힘이 됩니다.