중요한 부분 위주로 진행하겠습니다.
Yaml 파일
# 로컬용 profile # 로컬에서 서버를 구동시키고 postman으로 테스트하고 싶을 떄 spring: main: allow-circular-references: true datasource: url: "jdbc:mysql://localhost:3306/rg" username: dev password: dev driver-class-name: com.mysql.cj.jdbc.Driver jpa: show-sql: true generate-ddl: false database: mysql open-in-view: false hibernate: ddl-auto: none properties: hibernate: format_sql: true use_sql_comments: true security: oauth2: client: registration: kakao: client-name: kakao client-id: 8f248aa7874df072e8d15b2d0b284108 # REST API용 client-secret: tbGLY0lEfvxkrgFWfssEaXpWTS73nPJa # 보안 scope: profile_nickname, profile_image # 필수로 처리한 항목들 redirect-uri: "http://localhost:8080/login/oauth2/code/{registrationId}" # redirectURI , 마지막은 스프링 시큐리티에서 알아서 처리해서 넣어줌 authorization-grant-type: authorization_code # client-authentication-method: POST provider: kakao: authorization-uri: https://kauth.kakao.com/oauth/authorize # 1회성 인증을 받기위함 token-uri: https://kauth.kakao.com/oauth/token # 1회성 인증 코드를 이용해 accesscode를 발급받기 위함 user-info-uri: https://kapi.kakao.com/v2/user/me # kakao에서 사용자 정보를 가져오기 위한 api user-name-attribute: id # 카카오에서 사용자 정보를 가지고 왔을 때 사용자의 고유 식별키 추출을 위한 필드명, 사용자 정보 가져오기의 회원 번호 jwt: header: token issuer: prgrms client-secret: EENY5W0eegTf1naQB2eDeyCLl5kRS2b8xa5c4qLdS0hmVjtbvo8t0yhPMcAmtPuQ expiry-seconds: 60
위에서 부터 차례대로 설명하겠습니다.
- main
main: allow-circular-references: true
먼저 이 부분은 문제 해결한 곳에서 설명했듯이 Spring boot 2.6버전 이후로 순환참조를 사용할때 붙여줘야 하는 설정입니다. 해당 강의에서는 순환참조가 발생하기 때문에 붙였습니다. 현재 순환참조를 해결하는 방법을 모르기 때문에 설정을 붙여줌으로써 에러를 해결하였습니다.
- jpa
jpa: show-sql: true generate-ddl: false database: mysql open-in-view: false hibernate: ddl-auto: none properties: hibernate: format_sql: true use_sql_comments: true
jpa는 ddl-auto를 none으로 하고 진행하였는데 data.sql와 schema.sql을 설정할 것이 있어 이렇게 진행하였습니다. 실제 개발 코드에서는 최대한 쓰지 않는 방향으로 수정해보겠습니다.
- jwt
jwt: header: token issuer: prgrms client-secret: EENY5W0eegTf1naQB2eDeyCLl5kRS2b8xa5c4qLdS0hmVjtbvo8t0yhPMcAmtPuQ expiry-seconds: 60
security를 하기전에 먼저 설명해야 할 것 같아서 앞으로 빼서 설명하겠습니다.
- header: jwt토큰이 입력돼서 들어오는 헤더 이름을 의미한다.
- issuer: jwt토큰의 발행자
- client-secret: 토큰의 위변조 검증을 위한 시그니쳐, hs512알고리즘을 위한 64바이트 시크릿 키
- expiry-seconds: 토큰만료시간
- security
security: oauth2: client: registration: kakao: client-name: kakao client-id: 8f248aa7874df072e8d15b2d0b284108 # REST API용 client-secret: tbGLY0lEfvxkrgFWfssEaXpWTS73nPJa # 보안 scope: profile_nickname, profile_image # 필수로 처리한 항목들 redirect-uri: "http://localhost:8080/login/oauth2/code/{registrationId}" # redirectURI , 마지막은 스프링 시큐리티에서 알아서 처리해서 넣어줌 authorization-grant-type: authorization_code client-authentication-method: POST provider: kakao: authorization-uri: https://kauth.kakao.com/oauth/authorize # 1회성 인증을 받기위함 token-uri: https://kauth.kakao.com/oauth/token # 1회성 인증 코드를 이용해 accesscode를 발급받기 위함 user-info-uri: https://kapi.kakao.com/v2/user/me # kakao에서 사용자 정보를 가져오기 위한 api user-name-attribute: id # 카카오에서 사용자 정보를 가지고 왔을 때 사용자의 고유 식별키 추출을 위한 필드명, 사용자 정보 가져오기의 회원 번호
먼저 위에서부터 주석으로 일단 기본적인 내용은 썼습니다.
특별한 점은 client의 registration과 provider가 앞으로 네이버,구글이 추가된다면 저부분에 추가를 하고 실제로 필터에서 regfistration에 따라 다른 데이터를 가져가 쓰도록 구현되어있습니다. 또한 provider는 개발자도구의 실제 홈페이지를 기반으로 작성되어 있습니다.
OAuth 정리
Cookie 기반 인증
이번 OAuth는 Cookie를 기반으로 OAuth2 인증을 확인하는 방식으로 하였습니다. default는 Session 기반이기 때문에 쿠키로 바꾸기 위해 Custom 코드를 추가하였습니다. 만약 인증이 필요한 사이트의 경우 헤더는 아래와 같습니다.

Cookie에는 우리가 Base64로 인코딩한 쿠키가 들어있습니다. 만료시간, 이름 등도 설정되어 있습니다. 이를 위한 코드는 아래와 같습니다.
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> { private static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME"; private final String cookieName; // Cookie 이름 private final int cookieExpireSeconds; // 쿠키가 만료되는 시간 public HttpCookieOAuth2AuthorizationRequestRepository() { this(OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, 180); } public HttpCookieOAuth2AuthorizationRequestRepository(String cookieName, int cookieExpireSeconds) { this.cookieName = cookieName; this.cookieExpireSeconds = cookieExpireSeconds; } @Override public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { return getCookie(request) .map(this::getOAuth2AuthorizationRequest) .orElse(null); //쿠키를 조회해오고 쿠키가 조회된다면 get을 해주고 아니라면 null을 반환하게 된다. } @Override public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) { if (authorizationRequest == null) { // 만약 쿠키가 조회되면 쿠리를 초기화 시켜주자. getCookie(request).ifPresent(cookie -> clear(cookie, response)); } else { // 만약 조회가 된다면 String value = Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(authorizationRequest)); // authorizationRequest를 직렬화 하고 이를 Base64로 인코딩해서 저장한다. Cookie cookie = new Cookie(cookieName, value); cookie.setPath("/"); cookie.setHttpOnly(true); cookie.setMaxAge(cookieExpireSeconds); response.addCookie(cookie); } } @Override public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) { return loadAuthorizationRequest(request); // 이 메서드는 호출 자체가 되지 않는다. } @Override public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) { return getCookie(request) .map(cookie -> { OAuth2AuthorizationRequest oauth2Request = getOAuth2AuthorizationRequest(cookie); clear(cookie, response); return oauth2Request; }) .orElse(null); } private Optional<Cookie> getCookie( HttpServletRequest request) {// HttpServletRequest를 파라미터로 받고 쿠키를 가져오는 getCookie메서드 return Optional.ofNullable(WebUtils.getCookie(request, cookieName)); } private void clear(Cookie cookie, HttpServletResponse response) { //쿠키 초기화 cookie.setValue(""); // 비워야하기 때문에 cookie.setPath("/"); // 루트패스로 why? cookie.setMaxAge(0); //삭제해야하기때문에 response.addCookie(cookie); //최종적으로 이렇게 해주면 된다. } private OAuth2AuthorizationRequest getOAuth2AuthorizationRequest(Cookie cookie) { return SerializationUtils.deserialize( Base64.getUrlDecoder().decode(cookie.getValue())); //Byte Array로 오브젝트를 만들어야 한다. //Commonslang에 있는 간단한 유틸리티 클래스이다. 바이트어레이를 싱글 오브젝트로 바꿔주는 것이다. // 먼저 Base64로 인코딩된 쿠키를 디코딩한다. // 만약 세이브를 처리하는 오브젝트를 만들 때에는 현재 해놓은 처리와 정확하게 반대로 처리하면 된다. } }
조금 길수도 있지만 위의 코드가 쿠키를 생성하는 과정이기 때문에 중요하다고 생각하여 넣었습니다. 기본적인 내용들은 주석에 달았습니다. 또한 JSESSIONID, 즉 jwt token도 세션에 담아 사용합니다. 기본적으론 JwtAuthentication을 받아와 인증을 진행하게 됩니다.

- 해당 이미지를 보면 처음에 애플리케이션에 접근하고 인증 및 인가요청이 들어오게 됩니다.
- 이후 카카오로 인증을 요청하게 되고 요청이 완료되면 인가코드를 보내주게되고 다시 이를 토큰으로 교환하게 된다.
- 이렇게 나온 엑세스 코드들(refresh token, access token, client_registration_id 등)을 테이블에 저장하게 되고 이를 리소스서버에 요청하고 반환 받는 식으로 진행됩니다.
- 우리는 OAuth2AuthorizedClientService 코드를 jdbc기반으로 구현했기 때문에 테이블에서 꺼내서 인가서버에 전달해 인증받는 식으로 진행됩니다. 해당 셋팅은 Security Setting중 아래와 같습니다.
@Bean public OAuth2AuthorizedClientService auth2AuthorizedClientService(JdbcOperations jdbcOperations, ClientRegistrationRepository clientRegistrationRepository) { //jdbc기반이니까 테이블도 필요 return new JdbcOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository); // 파라미터들이 자동으로 등록됨 따라서 컨테이너꺼 쓰면됨 //기본적으로 oauth2-client-schema.sql이 spring security에 저장되어 있다. //이는 yml파일에 schema-locations에 추가해주자 } @Bean public OAuth2AuthorizedClientRepository authorizedClientRepository(OAuth2AuthorizedClientService oAuth2AuthorizedClientService) { return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(oAuth2AuthorizedClientService); }
또한 유저인증이 된 페이지를 다시 호출하게 된다면 jsessionid, jwt token, oauth cookie를 계속 갱신합니다. 이 Oauth cookie는 어떤 페이지를 가든 일단 생성이 됩니다. 하지만 로그인 되지 않은 페이지에서 다시 호출하게 된다면 Oauth cookie는 갱신되지 않습니다. 갱신이 된다면 그건 만료시간이 완료된 후에 다시 갱신이 됩니다.
만약 로그인되지 않은 유저가 로그인 권한이 필요한 페이지에 접근하게 된다면 카카오 로그인 페이지로 redirect하게 됩니다.
인증 방식
아까 말했듯이 먼저 인증,인가가 끝나면 Jwt토큰을 발급받습니다. 인증,인가는 처음이라면 카카오로 리다이렉트하여 해당 정보들을 저장하는 테이블, 그리고 유저또한 테이블에 저장합니다.
이렇게 받은 Jwt토큰을 이용하여 Authentication의 정보를 받게됩니다. 그리고 받은 정보를 이용해 탐색을 시작합니다. 수업에서 사용한 방식은 아래와 같습니다.
@GetMapping("/user/me") public UserDto me(@AuthenticationPrincipal JwtAuthentication authentication) { return userService.findByUsername(authentication.username) .map(user -> new UserDto(authentication.token, authentication.username, user.getGroup().getName())) .orElseThrow(() -> new IllegalArgumentException("Could not found user for " + authentication.username)); }
먼저 파라미터로 JwtAuthentication정보를 받고 여기에 username을 통해 유저를 검색하게 됩니다. 먼저 JwtAuthentication의 코드는 아래와 같습니다.
public class JwtAuthentication { public final String token; public final String username; JwtAuthentication(String token, String username) { checkArgument(isNotEmpty(token), "token must be provided."); checkArgument(isNotEmpty(username), "username must be provided."); this.token = token; this.username = username; } @Override public String toString() { return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) .append("token", token) .append("username", username) .toString(); } }
간단하게 token과 username을 저장하게 됩니다. token에는 jwt token 그리고 username에는 말 그대로 username(닉네임)을 저장하게 됩니다.
그리고 이 username을 통해 User Database를 조회합니다. 그리고 UserDto를 화면에 출력하게 되는데 아래와 같은 화면이 나옵니다.

인증 정리
결론적으로 우리가 사용하는 방식은 아래와 같습니다.
- 홈페이지 로그인 유지는 AccessToken을 통해 진행됩니다. 이미 AccessToken이 있다면 테이블에서 꺼내서 인증을 하고 아니라면 새로 토큰과 유저정보를 받아 저장합니다.
- 원래는 쿠키가 지속되는동안은 인증이 되어있어야 하지만 왜인지 자꾸 갱신이 됩니다. 이유는 잘 모르겠습니다. 더 공부해야할듯합니다.
- 그리고 위의 인증이 끝나면 JwtAuthentication에서 Username을 통해 User정보를 가져옵니다.
- 따라서 회원이 로그인 되어 있는지 아닌지는 JwtFilter에서 걸러져 redirect를 하게 됩니다.
- 만약 회원만 접근이 가능한 api를 설정하고 싶다면 configure에서 antMatchers에 권한과 url을 등록하면 됩니다. 예시는 아래와 같습니다. (어째서인지 “ADMIN”만 가능하게 설정해도 “USER”권한을 가진 접근이 가능하네요. 아시는분 있을까요?)
protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/user/me").hasAnyRole("USER", "ADMIN") .anyRequest().permitAll()
엔티티 정리
마지막으로 엔티티 정리가 있습니다.
- User : User가 저장되어 있습니다. 권한은 Authority_Groups를 FK로 가지고 있습니다.

- Authority_Groups : 해당 그룹이 어떤 그룹인지 저장되어 있습니다.

- Permission : 해당 그룹의 권한이 저장되어 있습니다.

- Group_Permission : Athority_Groups와 Permission의 중간 테이블입니다. 어드민에게는 유저와 어드민 권한을 둘 다 주도록 설정하였습니다.
