logo

𝝅번째 알파카의 개발 낙서장

screen

[OAuth2.0] ScribeJAVA로 OAuth2.0 인증서버 구축하기 - 8. 프로세스 구현하기

posts

JAVA

시리즈 톺아보기

OAuth2.0 인증서버 구축기

OAuth2.0 인증서버 구축기
count

개요 🔗

4장부터 7장까지 진행하며 각 플랫폼의 인증 모듈을 구현했다. 이 장에서는 모듈을 사용하는 주체인 프로세스를 구현한다.

프로세스는 HTTP 메소드에 따라 구분하여 관리한다.

구조 🔗

계정 관련 동작 이외엔 없으므로, 대분류는 account 하나 뿐이다.

작업에 필요한 HTTP 메소는 GET, POST, PUT, DELETE이므로, 아래와 같이 구분한다.

  • AccountGetProcess - 계정 GET 프로세스 클래스
  • AccountPostProcess - 계정 POST 프로세스 클래스
  • AccountPutProcess - 계정 PUT 프로세스 클래스
  • AccountDeleteProcess - 계정 DELETE 프로세스 클래스

GET 메소드에 사용하는 로직은 AccountGetProcess에 포함되는 식으로 구성한다.

프로세스 구현 🔗

Process 추상 클래스 구현 🔗

여러 프로세스에 공통 로직을 적용하기 위해, 모든 프로세스 객체에 상속할 Process 추상 클래스를 구현한다.

JAVA

0package global.module;
1
2import jakarta.servlet.http.HttpServletRequest;
3import jakarta.servlet.http.HttpServletResponse;
4import oauth.account.module.AuthModule;
5import oauth.account.module.GithubAuthModule;
6import oauth.account.module.GoogleAuthModule;
7import oauth.account.module.KakaoAuthModule;
8import oauth.account.module.NaverAuthModule;
9
10/**
11 * 프로세스 추상 클래스
12 *
13 * @author RWB
14 * @since 2021.09.30 Thu 01:14:25
15 */
16abstract public class Process
17{
18 protected HttpServletRequest request;
19 protected HttpServletResponse response;
20
21 /**
22 * 생성자 메서드
23 *
24 * @param request: [HttpServletRequest] HttpServletResponse 객체
25 * @param response: [HttpServletResponse] HttpServletResponse 객체
26 */
27 protected Process(HttpServletRequest request, HttpServletResponse response)
28 {
29 this.request = request;
30 this.response = response;
31 }
32
33 /**
34 * 인증 모듈 반환 메서드
35 *
36 * @param platform: [String] 플랫폼
37 *
38 * @return [AuthModule] AuthModule 객체
39 *
40 * @throws NullPointerException 유효하지 않은 플랫폼
41 */
42 protected AuthModule getAuthModule(String platform) throws NullPointerException
43 {
44 return switch (platform)
45 {
46 case "naver" -> NaverAuthModule.getInstance();
47 case "google" -> GoogleAuthModule.getInstance();
48 case "kakao" -> KakaoAuthModule.getInstance();
49 case "github" -> GithubAuthModule.getInstance();
50 default -> throw new NullPointerException(Util.builder("'", platform, "' is invalid platform"));
51 };
52 }
53}

서블릿 객체인 HttpServletRequest, HttpServletResponse에 쉽게 접근하기 위해 protected 접근 제어자로 각 지역변수를 선언한다.

생성자 사용 시 반드시 HttpServletRequest, HttpServletResponse를 인수로 주도록 강제한다.

이를 통해 Process를 상속하는 모든 하위 프로세스 클래스는 반드시 서블릿 객체를 인수로 받아야하며, 프로세스 내부에서 request, response로 서블릿 객체에 접근할 수 있다.


getAuthModule각 플랫폼 이름에 따라 해당하는 인스턴스를 반환하는 메서드다. 인증 모듈은 주로 프로세스에서 많이 사용하게 되므로, Process에 선언하여 이를 상속하는 모든 프로세스 클래스가 해당 메서드에 접근할 수 있도록 구성한다.

이러한 구성으로 동일한 프로세스에서 플랫폼별로 AuthModule 객체를 호출하여 플랫폼별로 선언한 메서드를 사용할 수 있다.

GET 프로세스 구현 🔗

계정 프로세스 중 GET에 해당하는 동작이 집합된 프로세스 클래스를 구현한다.

  • 인증 URL 응답 반환 메서드
  • 사용자 정보 응답 반환 메서드

GET에 해당하는 동작은 위 두 메서드다. 단순히 데이터를 받아오는 작업들로 구성되어있다.

인증 URL 응답 반환 메서드 🔗

플랫폼 로그인을 위한 인증 URL을 반환하는 메서드다.

AuthModulegetAuthorizationUrl 메서드를 통해 URL를 얻고, 이 내용을 담아 응답 객체로 만들어 반환한다.

JAVA

0public Response getAuthorizationUrlResponse(String platform)
1{
2 Response response;
3
4 ResponseBean<String> responseBean = new ResponseBean<>();
5
6 // 인증 URL 응답 생성 시도
7 try
8 {
9 String state = UUID.randomUUID().toString();
10
11 request.getSession().setAttribute("state", state);
12
13 AuthModule authModule = getAuthModule(platform);
14
15 responseBean.setFlag(true);
16 responseBean.setTitle("success");
17 responseBean.setMessage(Util.builder(platform, " authrorization url response success"));
18 responseBean.setBody(authModule.getAuthorizationUrl(state));
19
20 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build();
21 }
22
23 // 예외
24 catch (Exception e)
25 {
26 e.printStackTrace();
27
28 responseBean.setFlag(false);
29 responseBean.setTitle(e.getClass().getSimpleName());
30 responseBean.setMessage(e.getMessage());
31 responseBean.setBody(null);
32
33 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build();
34 }
35
36 return response;
37}

동일한 세션인지 확인하기 위해 프로세스 수행 시 state를 생성하여 getAuthorizationUrl에 전달한다. 해당 메서드가 반환하는 URL에 전달한 state가 URL 파라미터로 입력되어있을 것이다.

해당 state를 세션 애트리뷰트에도 등록한다.

플랫폼 로그인은 여러 창을 거치기 때문에, 요청 하이재킹이 매우 쉽다. 이 과정에서 세션 정보가 손상되기 쉬우므로 state를 통해 로그인 과정 전체가 동일한 세션에서 이루어지고 있는지 검증할 수 있다.

만약 URL의 state와 세션의 state가 일치하지 않거나, 세션 정보가 아예 없다면 정상적인 로그인 절차가 아니라고 판단할 수 있다.

추후 이 세션값은 Access Token을 받아 로그인 작업을 수행할 때 사용한다.

사용자 정보 응답 반환 메서드 🔗

Access Token을 통해 사용자 응답을 받는 메서드다.

AuthModulegetUserInfoBean 메서드를 통해 UserInfoBean 객체를 얻고, 이 내용을 담아 응답 객체로 만들어 반환한다.

JAVA

0public Response getUserInfoResponse(String accessCookie)
1{
2 Response response;
3
4 ResponseBean<UserInfoBean> responseBean = new ResponseBean<>();
5
6 // 사용자 정보 응답 생성 시도
7 try
8 {
9 Jws<Claims> jws = JwtModule.openJwt(accessCookie);
10
11 String accessToken = jws.getBody().get("access", String.class);
12 String platform = jws.getBody().get("platform", String.class);
13
14 AuthModule authModule = getAuthModule(platform);
15
16 com.github.scribejava.core.model.Response userInfoResponse = authModule.getUserInfo(accessToken);
17
18 // 응답이 정상적이지 않을 경우
19 if (userInfoResponse.getCode() != 200)
20 {
21 throw new OAuthResponseException(userInfoResponse);
22 }
23
24 responseBean.setFlag(true);
25 responseBean.setTitle("success");
26 responseBean.setMessage("user info response success");
27 responseBean.setBody(authModule.getUserInfoBean(userInfoResponse.getBody()));
28
29 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build();
30 }
31
32 // 예외
33 catch (Exception e)
34 {
35 e.printStackTrace();
36
37 responseBean.setFlag(false);
38 responseBean.setTitle(e.getClass().getSimpleName());
39 responseBean.setMessage(e.getMessage());
40 responseBean.setBody(null);
41
42 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build();
43 }
44
45 return response;
46}

추후 설명하겠지만, 로그인 시 Access Token과 Refresh Token을 각각 플랫폼과 함께 JWT로 생성하여 access, refresh 쿠키로 저장한다.

각 JWT 쿠키에 플랫폼 정보가 있으므로, access 쿠키만 있어도 Access Token와 그 플랫폼을 찾을 수 있다.

전체 코드 🔗

JAVA

0package oauth.account.process;
1
2import com.github.scribejava.core.model.OAuthResponseException;
3import global.bean.ResponseBean;
4import global.module.JwtModule;
5import global.module.Process;
6import global.module.Util;
7import io.jsonwebtoken.Claims;
8import io.jsonwebtoken.Jws;
9import jakarta.servlet.http.HttpServletRequest;
10import jakarta.servlet.http.HttpServletResponse;
11import jakarta.ws.rs.core.MediaType;
12import jakarta.ws.rs.core.Response;
13import oauth.account.bean.UserInfoBean;
14import oauth.account.module.AuthModule;
15
16import java.util.UUID;
17
18/**
19 * 계정 GET 프로세스 클래스
20 *
21 * @author RWB
22 * @since 2021.09.30 Thu 21:00:48
23 */
24public class AccountGetProcess extends Process
25{
26 /**
27 * 생성자 메서드
28 *
29 * @param request: [HttpServletRequest] HttpServletRequest 객체
30 * @param response: [HttpServletResponse] HttpServletResponse 객체
31 */
32 public AccountGetProcess(HttpServletRequest request, HttpServletResponse response)
33 {
34 super(request, response);
35 }
36
37 /**
38 * 인증 URL 응답 반환 메서드
39 *
40 * @param platform: [String] 플랫폼
41 *
42 * @return [Response] 응답 객체
43 */
44 public Response getAuthorizationUrlResponse(String platform)
45 {
46 Response response;
47
48 ResponseBean<String> responseBean = new ResponseBean<>();
49
50 // 인증 URL 응답 생성 시도
51 try
52 {
53 String state = UUID.randomUUID().toString();
54
55 request.getSession().setAttribute("state", state);
56
57 AuthModule authModule = getAuthModule(platform);
58
59 responseBean.setFlag(true);
60 responseBean.setTitle("success");
61 responseBean.setMessage(Util.builder(platform, " authrorization url response success"));
62 responseBean.setBody(authModule.getAuthorizationUrl(state));
63
64 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build();
65 }
66
67 // 예외
68 catch (Exception e)
69 {
70 e.printStackTrace();
71
72 responseBean.setFlag(false);
73 responseBean.setTitle(e.getClass().getSimpleName());
74 responseBean.setMessage(e.getMessage());
75 responseBean.setBody(null);
76
77 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build();
78 }
79
80 return response;
81 }
82
83 /**
84 * 사용자 정보 응답 반환 메서드
85 *
86 * @param accessCookie: [String] 접근 토큰 쿠키
87 *
88 * @return [Response] 응답 객체
89 */
90 public Response getUserInfoResponse(String accessCookie)
91 {
92 Response response;
93
94 ResponseBean<UserInfoBean> responseBean = new ResponseBean<>();
95
96 // 사용자 정보 응답 생성 시도
97 try
98 {
99 Jws<Claims> jws = JwtModule.openJwt(accessCookie);
100
101 String accessToken = jws.getBody().get("access", String.class);
102 String platform = jws.getBody().get("platform", String.class);
103
104 AuthModule authModule = getAuthModule(platform);
105
106 com.github.scribejava.core.model.Response userInfoResponse = authModule.getUserInfo(accessToken);
107
108 // 응답이 정상적이지 않을 경우
109 if (userInfoResponse.getCode() != 200)
110 {
111 throw new OAuthResponseException(userInfoResponse);
112 }
113
114 responseBean.setFlag(true);
115 responseBean.setTitle("success");
116 responseBean.setMessage("user info response success");
117 responseBean.setBody(authModule.getUserInfoBean(userInfoResponse.getBody()));
118
119 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build();
120 }
121
122 // 예외
123 catch (Exception e)
124 {
125 e.printStackTrace();
126
127 responseBean.setFlag(false);
128 responseBean.setTitle(e.getClass().getSimpleName());
129 responseBean.setMessage(e.getMessage());
130 responseBean.setBody(null);
131
132 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build();
133 }
134
135 return response;
136 }
137}

POST 프로세스 구현 🔗

계정 프로세스 중 POST에 해당하는 동작이 집합된 프로세스 클래스를 구현한다.

  • 로그인 응답 반환 메서드
  • 자동 로그인 응답 반환 메서드
  • 로그아웃 응답 반환 메서드

POST에 해당하는 동작은 위 두 메서드다. 주로 로그인/로그아웃 작업으로 구성되어있다.

로그인 응답 반환 메서드 🔗

플랫폼 로그인 이후 발급되는 code를 통해 Access Token으로 교환하여 로그인을 수행하는 메서드다.

AuthModulegetAccessToken 메서드를 통해 OAuth2AccessToken 객체를 반환받아 Access Token, Refresh Token을 추출한다.

이 토큰들을 JWT 쿠키로 만들어 로그인 절차를 수행한다.

JAVA

0public Response postLoginResponse(String platform, String code, String state)
1{
2 Response response;
3
4 ResponseBean<String> responseBean = new ResponseBean<>();
5
6 HttpSession session = request.getSession();
7
8 // 로그인 응답 생성 시도
9 try
10 {
11 Object sessionState = Objects.requireNonNull(session.getAttribute("state"));
12
13 // 고유 상태값이 일치하지 않을 경우
14 if (!state.equals(sessionState))
15 {
16 throw new BadAttributeValueExpException("state is mismatched");
17 }
18
19 AuthModule authModule = getAuthModule(platform);
20
21 OAuth2AccessToken oAuth2AccessToken = authModule.getAccessToken(code);
22
23 String accessToken = oAuth2AccessToken.getAccessToken();
24 String refreshToken = oAuth2AccessToken.getRefreshToken();
25
26 HashMap<String, Object> accessMap = new HashMap<>();
27 accessMap.put("access", accessToken);
28 accessMap.put("platform", platform);
29
30 HashMap<String, Object> refreshMap = new HashMap<>();
31 refreshMap.put("refresh", refreshToken);
32 refreshMap.put("platform", platform);
33
34 String accessJwt = JwtModule.generateJwt(state, accessMap);
35 String refreshJwt = JwtModule.generateJwt(state, refreshMap);
36
37 NewCookie accessCookie = new NewCookie("access", accessJwt, "/oauth2", ".itcode.dev", "access token", -1, true, true);
38 NewCookie refreshCookie = new NewCookie("refresh", refreshJwt, "/oauth2", ".itcode.dev", "refresh token", refreshToken == null ? 0 : 86400 * 7 + 3600 * 9, true, true);
39
40 responseBean.setFlag(true);
41 responseBean.setTitle("success");
42 responseBean.setMessage("authorized success");
43 responseBean.setBody(null);
44
45 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).cookie(accessCookie, refreshCookie).build();
46 }
47
48 // 예외
49 catch (Exception e)
50 {
51 e.printStackTrace();
52
53 responseBean.setFlag(false);
54 responseBean.setTitle(e.getClass().getSimpleName());
55 responseBean.setMessage(e.getMessage());
56 responseBean.setBody(null);
57
58 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build();
59 }
60
61 // 시도 후
62 finally
63 {
64 session.invalidate();
65 }
66
67 return response;
68}

AccountGetProcessgetAuthorizationUrlResponse 동작 중 세션 애트리뷰트에 state를 입력했었는데, 여기서 그 세션값을 통해 검증을 수행한다.

URL을 통해 인수로 받은 state와 세션의 state를 추출하여 비교하고, 동일하지 않을 경우 예외를 발생시킨다. 중간에 URL을 탈취해서 전혀 다른 code를 삽입하여 요청을 보내도 이를 방지할 수 있는 보안책인 셈이다.

Access Token과 Refresh Token을 전달받아 JWT 쿠키로 만든다.


  • Access Token JWT

JSON

0{
1 "iss": "oauth2",
2 "sub": "auth",
3 "aud": "c9159786-40bf-4cf2-8c93-f683d1070137",
4 "access": "{ACCESS_TOKEN}",
5 "platform": "naver",
6 "exp": 1634986011,
7 "nbf": 1634982411,
8 "iat": 1634982411,
9 "jti": "c9159786-40bf-4cf2-8c93-f683d1070137"
10}
  • Refresh Token JWT

JSON

0{
1 "iss": "oauth2",
2 "sub": "auth",
3 "aud": "c9159786-40bf-4cf2-8c93-f683d1070137",
4 "refresh": "{REFRESH_TOKEN}",
5 "platform": "naver",
6 "exp": 1634986011,
7 "nbf": 1634982411,
8 "iat": 1634982411,
9 "jti": "c9159786-40bf-4cf2-8c93-f683d1070137"
10}

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

0public Response postAutoLoginResponse(String accessCookie, String refreshCookie)
1{
2 Response response;
3
4 ResponseBean<String> responseBean = new ResponseBean<>();
5
6 // 자동 로그인 시도
7 try
8 {
9 // 접근 토큰 쿠키가 있을 경우
10 if (accessCookie != null)
11 {
12 responseBean.setFlag(true);
13 responseBean.setTitle("success");
14 responseBean.setMessage("auto authorized success");
15 responseBean.setBody(null);
16
17 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build();
18 }
19
20 // 리프레쉬 토큰 쿠키가 없을 경우
21 else if (refreshCookie == null)
22 {
23 responseBean.setFlag(false);
24 responseBean.setTitle("fail");
25 responseBean.setMessage("refresh token is empty");
26 responseBean.setBody(null);
27
28 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build();
29 }
30
31 // 리프레쉬 토큰 쿠키가 있을 경우
32 else
33 {
34 Jws<Claims> refreshJws = JwtModule.openJwt(refreshCookie);
35
36 String refreshToken = refreshJws.getBody().get("refresh", String.class);
37 String platform = refreshJws.getBody().get("platform", String.class);
38
39 AuthModule authModule = getAuthModule(platform);
40
41 OAuth2AccessToken oAuth2AccessToken = authModule.getRefreshAccessToken(refreshToken);
42
43 String accessToken = oAuth2AccessToken.getAccessToken();
44
45 HashMap<String, Object> accessMap = new HashMap<>();
46 accessMap.put("access", accessToken);
47 accessMap.put("platform", platform);
48
49 HashMap<String, Object> refreshMap = new HashMap<>();
50 refreshMap.put("refresh", refreshToken);
51 refreshMap.put("platform", platform);
52
53 String uuid = UUID.randomUUID().toString();
54
55 String accessJwt = JwtModule.generateJwt(uuid, accessMap);
56 String refreshJwt = JwtModule.generateJwt(uuid, refreshMap);
57
58 NewCookie newAccessCookie = new NewCookie("access", accessJwt, "/oauth2", ".itcode.dev", "access token", -1, true, true);
59 NewCookie newRefreshCookie = new NewCookie("refresh", refreshJwt, "/oauth2", ".itcode.dev", "refresh token", 86400 * 7 + 3600 * 9, true, true);
60
61 responseBean.setFlag(true);
62 responseBean.setTitle("success");
63 responseBean.setMessage("auto authorized success");
64 responseBean.setBody(null);
65
66 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).cookie(newAccessCookie, newRefreshCookie).build();
67 }
68 }
69
70 // 예외
71 catch (Exception e)
72 {
73 e.printStackTrace();
74
75 responseBean.setFlag(false);
76 responseBean.setTitle(e.getClass().getSimpleName());
77 responseBean.setMessage(e.getMessage());
78 responseBean.setBody(null);
79
80 NewCookie newAccessCookie = new NewCookie("access", null, "/oauth2", ".itcode.dev", "access token", 0, true, true);
81 NewCookie newRefreshCookie = new NewCookie("refresh", null, "/oauth2", ".itcode.dev", "refresh token", 0, true, true);
82
83 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).cookie(newAccessCookie, newRefreshCookie).build();
84 }
85
86 return response;
87}

access 쿠키가 이미 있을 경우, 이미 인증 정보가 있기 때문에 별다른 동작을 취하지 않고 넘어간다.

만약 access 쿠키는 없고 refresh 쿠키만 있다면, 이를 통해 Access Token을 재발급받아 인증 정보를 갱신하여 자동으로 로그인을 수행한다.

로그인 로직 자체는 기존 로그인 메서드와 동일하며, Access Token만 Refresh Token을 통해 갱신하여 사용한다.

로그아웃 응답 반환 메서드 🔗

인증 정보를 제거하여 로그아웃을 수행하는 메서드.

JAVA

0public Response postLogoutResponse()
1{
2 Response response;
3
4 ResponseBean<String> responseBean = new ResponseBean<>();
5
6 // 로그아웃 응답 생성 시도
7 try
8 {
9 NewCookie accessCookie = new NewCookie("access", null, "/oauth2", ".itcode.dev", "access token", 0, true, true);
10 NewCookie refreshCookie = new NewCookie("refresh", null, "/oauth2", ".itcode.dev", "refresh token", 0, true, true);
11
12 responseBean.setFlag(true);
13 responseBean.setTitle("success");
14 responseBean.setMessage("logout success");
15 responseBean.setBody(null);
16
17 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).cookie(accessCookie, refreshCookie).build();
18 }
19
20 // 예외
21 catch (Exception e)
22 {
23 e.printStackTrace();
24
25 responseBean.setFlag(false);
26 responseBean.setTitle(e.getClass().getSimpleName());
27 responseBean.setMessage(e.getMessage());
28 responseBean.setBody(null);
29
30 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build();
31 }
32
33 return response;
34}

인증정보는 쿠키 기반이다. 서버에서 쿠키 생성 시 보안을 위해 HttpOnly 옵션을 활성화했기 때문에 JavaScript에선 access, refresh 쿠키를 다룰 수 없다.

서버에서 쿠키 만료시간을 0으로 덮어씌워 인증쿠키를 제거한다.

전체 코드 🔗

JAVA

0package oauth.account.process;
1
2import com.github.scribejava.core.model.OAuth2AccessToken;
3import global.bean.ResponseBean;
4import global.module.JwtModule;
5import global.module.Process;
6import io.jsonwebtoken.Claims;
7import io.jsonwebtoken.Jws;
8import jakarta.servlet.http.HttpServletRequest;
9import jakarta.servlet.http.HttpServletResponse;
10import jakarta.servlet.http.HttpSession;
11import jakarta.ws.rs.core.MediaType;
12import jakarta.ws.rs.core.NewCookie;
13import jakarta.ws.rs.core.Response;
14import oauth.account.module.AuthModule;
15
16import javax.management.BadAttributeValueExpException;
17import java.util.HashMap;
18import java.util.Objects;
19import java.util.UUID;
20
21/**
22 * 계정 POST 프로세스 클래스
23 *
24 * @author RWB
25 * @since 2021.10.02 Sat 00:53:52
26 */
27public class AccountPostProcess extends Process
28{
29 /**
30 * 생성자 메서드
31 *
32 * @param request: [HttpServletRequest] HttpServletRequest 객체
33 * @param response: [HttpServletResponse] HttpServletResponse 객체
34 */
35 public AccountPostProcess(HttpServletRequest request, HttpServletResponse response)
36 {
37 super(request, response);
38 }
39
40 /**
41 * 로그인 응답 반환 메서드
42 *
43 * @param platform: [String] 플랫폼
44 * @param code: [String] 인증 코드
45 * @param state: [String] 고유 상태값
46 *
47 * @return [Response] 응답 객체
48 */
49 public Response postLoginResponse(String platform, String code, String state)
50 {
51 Response response;
52
53 ResponseBean<String> responseBean = new ResponseBean<>();
54
55 HttpSession session = request.getSession();
56
57 // 로그인 응답 생성 시도
58 try
59 {
60 Object sessionState = Objects.requireNonNull(session.getAttribute("state"));
61
62 // 고유 상태값이 일치하지 않을 경우
63 if (!state.equals(sessionState))
64 {
65 throw new BadAttributeValueExpException("state is mismatched");
66 }
67
68 AuthModule authModule = getAuthModule(platform);
69
70 OAuth2AccessToken oAuth2AccessToken = authModule.getAccessToken(code);
71
72 String accessToken = oAuth2AccessToken.getAccessToken();
73 String refreshToken = oAuth2AccessToken.getRefreshToken();
74
75 HashMap<String, Object> accessMap = new HashMap<>();
76 accessMap.put("access", accessToken);
77 accessMap.put("platform", platform);
78
79 HashMap<String, Object> refreshMap = new HashMap<>();
80 refreshMap.put("refresh", refreshToken);
81 refreshMap.put("platform", platform);
82
83 String accessJwt = JwtModule.generateJwt(state, accessMap);
84 String refreshJwt = JwtModule.generateJwt(state, refreshMap);
85
86 NewCookie accessCookie = new NewCookie("access", accessJwt, "/oauth2", ".itcode.dev", "access token", -1, true, true);
87 NewCookie refreshCookie = new NewCookie("refresh", refreshJwt, "/oauth2", ".itcode.dev", "refresh token", refreshToken == null ? 0 : 86400 * 7 + 3600 * 9, true, true);
88
89 responseBean.setFlag(true);
90 responseBean.setTitle("success");
91 responseBean.setMessage("authorized success");
92 responseBean.setBody(null);
93
94 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).cookie(accessCookie, refreshCookie).build();
95 }
96
97 // 예외
98 catch (Exception e)
99 {
100 e.printStackTrace();
101
102 responseBean.setFlag(false);
103 responseBean.setTitle(e.getClass().getSimpleName());
104 responseBean.setMessage(e.getMessage());
105 responseBean.setBody(null);
106
107 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build();
108 }
109
110 // 시도 후
111 finally
112 {
113 session.invalidate();
114 }
115
116 return response;
117 }
118
119 /**
120 * 자동 로그인 응답 반환 메서드
121 *
122 * @param accessCookie: [String] 접근 토큰 쿠키
123 * @param refreshCookie: [String] 리프레쉬 토큰 쿠키
124 *
125 * @return [Response] 응답 객체
126 */
127 public Response postAutoLoginResponse(String accessCookie, String refreshCookie)
128 {
129 Response response;
130
131 ResponseBean<String> responseBean = new ResponseBean<>();
132
133 // 자동 로그인 시도
134 try
135 {
136 // 접근 토큰 쿠키가 있을 경우
137 if (accessCookie != null)
138 {
139 responseBean.setFlag(true);
140 responseBean.setTitle("success");
141 responseBean.setMessage("auto authorized success");
142 responseBean.setBody(null);
143
144 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build();
145 }
146
147 // 리프레쉬 토큰 쿠키가 없을 경우
148 else if (refreshCookie == null)
149 {
150 responseBean.setFlag(false);
151 responseBean.setTitle("fail");
152 responseBean.setMessage("refresh token is empty");
153 responseBean.setBody(null);
154
155 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build();
156 }
157
158 // 리프레쉬 토큰 쿠키가 있을 경우
159 else
160 {
161 Jws<Claims> refreshJws = JwtModule.openJwt(refreshCookie);
162
163 String refreshToken = refreshJws.getBody().get("refresh", String.class);
164 String platform = refreshJws.getBody().get("platform", String.class);
165
166 AuthModule authModule = getAuthModule(platform);
167
168 OAuth2AccessToken oAuth2AccessToken = authModule.getRefreshAccessToken(refreshToken);
169
170 String accessToken = oAuth2AccessToken.getAccessToken();
171
172 HashMap<String, Object> accessMap = new HashMap<>();
173 accessMap.put("access", accessToken);
174 accessMap.put("platform", platform);
175
176 HashMap<String, Object> refreshMap = new HashMap<>();
177 refreshMap.put("refresh", refreshToken);
178 refreshMap.put("platform", platform);
179
180 String uuid = UUID.randomUUID().toString();
181
182 String accessJwt = JwtModule.generateJwt(uuid, accessMap);
183 String refreshJwt = JwtModule.generateJwt(uuid, refreshMap);
184
185 NewCookie newAccessCookie = new NewCookie("access", accessJwt, "/oauth2", ".itcode.dev", "access token", -1, true, true);
186 NewCookie newRefreshCookie = new NewCookie("refresh", refreshJwt, "/oauth2", ".itcode.dev", "refresh token", 86400 * 7 + 3600 * 9, true, true);
187
188 responseBean.setFlag(true);
189 responseBean.setTitle("success");
190 responseBean.setMessage("auto authorized success");
191 responseBean.setBody(null);
192
193 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).cookie(newAccessCookie, newRefreshCookie).build();
194 }
195 }
196
197 // 예외
198 catch (Exception e)
199 {
200 e.printStackTrace();
201
202 responseBean.setFlag(false);
203 responseBean.setTitle(e.getClass().getSimpleName());
204 responseBean.setMessage(e.getMessage());
205 responseBean.setBody(null);
206
207 NewCookie newAccessCookie = new NewCookie("access", null, "/oauth2", ".itcode.dev", "access token", 0, true, true);
208 NewCookie newRefreshCookie = new NewCookie("refresh", null, "/oauth2", ".itcode.dev", "refresh token", 0, true, true);
209
210 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).cookie(newAccessCookie, newRefreshCookie).build();
211 }
212
213 return response;
214 }
215
216 /**
217 * 로그아웃 응답 반환 메서드
218 *
219 * @return [Response] 응답 객체
220 */
221 public Response postLogoutResponse()
222 {
223 Response response;
224
225 ResponseBean<String> responseBean = new ResponseBean<>();
226
227 // 로그아웃 응답 생성 시도
228 try
229 {
230 NewCookie accessCookie = new NewCookie("access", null, "/oauth2", ".itcode.dev", "access token", 0, true, true);
231 NewCookie refreshCookie = new NewCookie("refresh", null, "/oauth2", ".itcode.dev", "refresh token", 0, true, true);
232
233 responseBean.setFlag(true);
234 responseBean.setTitle("success");
235 responseBean.setMessage("logout success");
236 responseBean.setBody(null);
237
238 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).cookie(accessCookie, refreshCookie).build();
239 }
240
241 // 예외
242 catch (Exception e)
243 {
244 e.printStackTrace();
245
246 responseBean.setFlag(false);
247 responseBean.setTitle(e.getClass().getSimpleName());
248 responseBean.setMessage(e.getMessage());
249 responseBean.setBody(null);
250
251 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build();
252 }
253
254 return response;
255 }
256}

PUT 프로세스 구현 🔗

계정 프로세스 중 PUT에 해당하는 동작이 집합된 프로세스를 구현한다.

  • 정보 제공 동의 갱신 URL 응답 반환 메서드

PUT에 해당하는 메서드는 하나다. 데이터를 수정하는 작업들로 구성되어있다.

정보 제공 동의 갱신 URL 응답 반환 메서드 🔗

정보 제공 동의를 새로 갱신하는 URL을 반환하는 메서드다.

JAVA

0public Response putUpdateAuthorizationUrl(String accessCookie)
1{
2 Response response;
3
4 ResponseBean<String> responseBean = new ResponseBean<>();
5
6 // 정보 제공 동의 갱신 URL 응답 생성 시도
7 try
8 {
9 String state = UUID.randomUUID().toString();
10
11 Jws<Claims> jws = JwtModule.openJwt(accessCookie);
12
13 String platform = jws.getBody().get("platform", String.class);
14
15 AuthModule authModule = getAuthModule(platform);
16
17 String url = authModule.getUpdateAuthorizationUrl(state);
18
19 // URL이 null일 경우
20 if (url == null)
21 {
22 responseBean.setFlag(false);
23 responseBean.setTitle("skipped");
24 responseBean.setMessage(Util.builder(platform, " doesn't need that service"));
25 responseBean.setBody(null);
26 }
27
28 // URL이 유효할 경우
29 else
30 {
31 request.getSession().setAttribute("state", state);
32
33 responseBean.setFlag(true);
34 responseBean.setTitle("success");
35 responseBean.setMessage(Util.builder(platform, " reauthrorization url response success"));
36 responseBean.setBody(url);
37 }
38
39 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build();
40 }
41
42 // 예외
43 catch (Exception e)
44 {
45 e.printStackTrace();
46
47 responseBean.setFlag(false);
48 responseBean.setTitle(e.getClass().getSimpleName());
49 responseBean.setMessage(e.getMessage());
50 responseBean.setBody(null);
51
52 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build();
53 }
54
55 return response;
56}

첫 로그인 시, 서비스가 요구하는 정보에 대해 동의/거부하는 절차를 거친다. 이후 유저 정보 요청 시, 이 정보 제공 동의 여부에 의거하여 동의한 정보만을 제공한다.

만약 서비스 운영 도중 요구 정보가 변경되어 추가적인 정보가 필요하다면, 정보 제공 동의를 갱신할 필요가 있다.

서비스는 반환받은 URL로 리다이렉션하여 동의를 갱신한 뒤 갱신된 정보가 반영된 code를 반환한다. 이후의 과정은 로그인과 동일한 과정을 거친다.


즉, 정보 제공 동의는 새로운 정보를 갱신하여 로그인을 다시 수행하는 것과 동일하다.

전체 코드 🔗

JAVA

0package oauth.account.process;
1
2import global.bean.ResponseBean;
3import global.module.JwtModule;
4import global.module.Process;
5import global.module.Util;
6import io.jsonwebtoken.Claims;
7import io.jsonwebtoken.Jws;
8import jakarta.servlet.http.HttpServletRequest;
9import jakarta.servlet.http.HttpServletResponse;
10import jakarta.ws.rs.core.MediaType;
11import jakarta.ws.rs.core.Response;
12import oauth.account.module.AuthModule;
13
14import java.util.UUID;
15
16/**
17 * 계정 PUT 프로세스 클래스
18 *
19 * @author RWB
20 * @since 2021.10.19 Tue 21:56:32
21 */
22public class AccountPutProcess extends Process
23{
24 /**
25 * 생성자 메서드
26 *
27 * @param request: [HttpServletRequest] HttpServletRequest 객체
28 * @param response: [HttpServletResponse] HttpServletResponse 객체
29 */
30 public AccountPutProcess(HttpServletRequest request, HttpServletResponse response)
31 {
32 super(request, response);
33 }
34
35 /**
36 * 정보 제공 동의 갱신 URL 응답 반환 메서드
37 *
38 * @param accessCookie: [String] 접근 토큰 쿠키
39 *
40 * @return [Response] 응답 객체
41 */
42 public Response putUpdateAuthorizationUrl(String accessCookie)
43 {
44 Response response;
45
46 ResponseBean<String> responseBean = new ResponseBean<>();
47
48 // 정보 제공 동의 갱신 URL 응답 생성 시도
49 try
50 {
51 String state = UUID.randomUUID().toString();
52
53 Jws<Claims> jws = JwtModule.openJwt(accessCookie);
54
55 String platform = jws.getBody().get("platform", String.class);
56
57 AuthModule authModule = getAuthModule(platform);
58
59 String url = authModule.getUpdateAuthorizationUrl(state);
60
61 // URL이 null일 경우
62 if (url == null)
63 {
64 responseBean.setFlag(false);
65 responseBean.setTitle("skipped");
66 responseBean.setMessage(Util.builder(platform, " doesn't need that service"));
67 responseBean.setBody(null);
68 }
69
70 // URL이 유효할 경우
71 else
72 {
73 request.getSession().setAttribute("state", state);
74
75 responseBean.setFlag(true);
76 responseBean.setTitle("success");
77 responseBean.setMessage(Util.builder(platform, " reauthrorization url response success"));
78 responseBean.setBody(url);
79 }
80
81 response = Response.ok(responseBean, MediaType.APPLICATION_JSON).build();
82 }
83
84 // 예외
85 catch (Exception e)
86 {
87 e.printStackTrace();
88
89 responseBean.setFlag(false);
90 responseBean.setTitle(e.getClass().getSimpleName());
91 responseBean.setMessage(e.getMessage());
92 responseBean.setBody(null);
93
94 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build();
95 }
96
97 return response;
98 }
99}

DELETE 프로세스 구현 🔗

계정 프로세스 중 DELETE에 해당하는 동작이 집합된 프로세스를 구현한다.

  • 연동 해제 응답 반환 메서드

DELETE에 해당하는 메서드는 하나다. 주로 데이터를 삭제하는 작업들로 구성되어있다.

연동 해제 응답 반환 메서드 🔗

플랫폼과의 연동을 완전히 해제하고 로그아웃을 수행하는 메서드다.

JAVA

0public Response deleteInfoResponse(String accessCookie)
1{
2 Response response;
3
4 ResponseBean<String> responseBean = new ResponseBean<>();
5
6 // 연동 해제 응답 생성 시도
7 try
8 {
9 Jws<Claims> jws = JwtModule.openJwt(accessCookie);
10
11 String accessToken = jws.getBody().get("access", String.class);
12 String platform = jws.getBody().get("platform", String.class);
13
14 AuthModule authModule = getAuthModule(platform);
15
16 // 연동 해제에 성공할 경우
17 if (authModule.deleteInfo(accessToken))
18 {
19 response = new AccountPostProcess(request, this.response).postLogoutResponse();
20 }
21
22 // 아닐 경우
23 else
24 {
25 throw new RequestAuthenticationException("revoke fail");
26 }
27 }
28
29 // 예외
30 catch (Exception e)
31 {
32 e.printStackTrace();
33
34 responseBean.setFlag(false);
35 responseBean.setTitle(e.getClass().getSimpleName());
36 responseBean.setMessage(e.getMessage());
37 responseBean.setBody(null);
38
39 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build();
40 }
41
42 return response;
43}

플랫폼과의 연동을 해제하면 발급받았던 Access Token과 Refresh Token이 만료되어 더 이상 제 기능을 할 수 없게 된다.

보통 회원탈퇴 시 이루어지는 과정이지만, 이 프로젝트는 별도의 회원가입이랄게 따로 없으므로, 자동으로 로그아웃을 시키는 것으로 끝난다.

추후 재로그인 시 처음 로그인하는 것과 동일한 과정을 거치게 된다.

전체 코드 🔗

JAVA

0package oauth.account.process;
1
2import global.bean.ResponseBean;
3import global.module.JwtModule;
4import global.module.Process;
5import io.jsonwebtoken.Claims;
6import io.jsonwebtoken.Jws;
7import jakarta.servlet.http.HttpServletRequest;
8import jakarta.servlet.http.HttpServletResponse;
9import jakarta.ws.rs.core.MediaType;
10import jakarta.ws.rs.core.Response;
11import oauth.account.module.AuthModule;
12import org.glassfish.jersey.client.authentication.RequestAuthenticationException;
13
14/**
15 * 계정 DELETE 프로세스 클래스
16 *
17 * @author RWB
18 * @since 2021.10.02 Sat 00:53:52
19 */
20public class AccountDeleteProcess extends Process
21{
22 /**
23 * 생성자 메서드
24 *
25 * @param request: [HttpServletRequest] HttpServletRequest 객체
26 * @param response: [HttpServletResponse] HttpServletResponse 객체
27 */
28 public AccountDeleteProcess(HttpServletRequest request, HttpServletResponse response)
29 {
30 super(request, response);
31 }
32
33 /**
34 * 연동 해제 응답 반환 메서드
35 *
36 * @param accessCookie: [String] 접근 토큰 쿠키
37 *
38 * @return [Response] 응답 객체
39 */
40 public Response deleteInfoResponse(String accessCookie)
41 {
42 Response response;
43
44 ResponseBean<String> responseBean = new ResponseBean<>();
45
46 // 연동 해제 응답 생성 시도
47 try
48 {
49 Jws<Claims> jws = JwtModule.openJwt(accessCookie);
50
51 String accessToken = jws.getBody().get("access", String.class);
52 String platform = jws.getBody().get("platform", String.class);
53
54 AuthModule authModule = getAuthModule(platform);
55
56 // 연동 해제에 성공할 경우
57 if (authModule.deleteInfo(accessToken))
58 {
59 response = new AccountPostProcess(request, this.response).postLogoutResponse();
60 }
61
62 // 아닐 경우
63 else
64 {
65 throw new RequestAuthenticationException("revoke fail");
66 }
67 }
68
69 // 예외
70 catch (Exception e)
71 {
72 e.printStackTrace();
73
74 responseBean.setFlag(false);
75 responseBean.setTitle(e.getClass().getSimpleName());
76 responseBean.setMessage(e.getMessage());
77 responseBean.setBody(null);
78
79 response = Response.status(Response.Status.BAD_REQUEST).entity(responseBean).type(MediaType.APPLICATION_JSON).build();
80 }
81
82 return response;
83 }
84}

정리 🔗

이로써 프로젝트 구현을 완료했다. NaverAuthModule, GoogleAuthModule 같은 각기 다른 인증모듈을 AuthModule이라는 상위 객체로 반환받은 덕분에 복잡한 분기나 중복 코드를 막을 수 있었다.

파이프라인이 나눠지는 순간, 이와 연결된 하위 파이프라인까지 강제로 분리되는 경향이 있다. 가장 밑단인 모듈을 적절히 설계한 덕분에, 그 상위 파이프라인들은 하나로 관리할 수 있음을 확인할 수 있다.

다음 장에서는 Jersey를 통한 컨트롤러 구성 방법에 대해 다룬다.