코딩공작소

Springboot3 실습 (5) - Oauth2로 로그인/로그아웃 구현(2) 본문

어플리케이션개발/springboot실습

Springboot3 실습 (5) - Oauth2로 로그인/로그아웃 구현(2)

안잡아모찌 2024. 4. 17. 18:53

의존성 추가하기

OAuth2를 사용하기 위한 스타터 추가

 

 

쿠키 관리 클래스 추가

  • addCookie : 요청 값을 바탕으로 HTTP 응답에 쿠키를 추가합니다.
  • 쿠키 이름을 입력받아 쿠키를 삭제한다. 실제 삭제 방법은 없어서 파라미터로 넘어온 키의 쿠키를 빈 값으로 바꾸고 만료기간을 0으로 설정해서 쿠키가 재생성 되자마자 만료처리한다.

  • serialize : 객체를 직렬화해 쿠키의 값으로 들어갈 값으로 변환
  • deserialize : 쿠키를 역직렬화해 객체로 변환

 

 

OAuth2 서비스 구현

사용자 정보를 조회해 users 테이블에 사용자 정보가 있다면 리소스 서버에서 제공해주는 이름을 업데이트
정보가 없다면 새 사용자를 생성해 데이터베이스에 저장하는 서비스를 구현

domain 패키지의 User.java 파일에 사용자 이름과 OAuth 관련 키를 저장하는 코드 추가

 

 

config > oauth 패키지에 클래스를 생성한다.
loadUser()를 통해 사용자를 조회하고 사용자가 users 테이블에 사용자 정보가 있다면 이름을 업데이트하고 없다면 saveOrUpdate() 메서드를 실행해 users 테이블에 회원 데이터를 추가한다.

 

 

약간 복잡한 OAuth2 설정 파일 작성

OAuth2와 JWT를 함께 사용하려면 설정 파일들을 알맞게 수정 해야한다.

 

기존 WebSecurityConfig.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
package study.springbootdeveloper.config;
 
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import study.springbootdeveloper.BlogService.UserService;
import study.springbootdeveloper.config.jwt.TokenProvider;
import study.springbootdeveloper.config.oauth.OAuth2AuthorizationRequestBasedOnCookieRepository;
import study.springbootdeveloper.config.oauth.OAuth2SuccessHandler;
import study.springbootdeveloper.config.oauth.OAuth2UserCustomService;
import study.springbootdeveloper.repository.RefreshTokenRepository;
 
import static org.springframework.boot.autoconfigure.security.servlet.PathRequest.toH2Console;
 
@RequiredArgsConstructor
@Configuration
public class WebOAuthSecurityConfig {
    private final OAuth2UserCustomService oAuth2UserCustomService;
    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final UserService userService;
 
    @Bean
    public WebSecurityCustomizer configure() {
        //스프링 시큐리티 기능 비활성화
        return (web) -> web.ignoring()
                .requestMatchers(toH2Console())
                .requestMatchers("/img/**""/css/**""/js/**");
    }
 
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 토큰 방식으로 인증을 하기 때문에 기존에 사용하던 폼로그인, 세션 비활성화 .. (1)
        http.csrf().disable()
                .httpBasic().disable()
                .formLogin().disable()
                .logout().disable();
 
        http.sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
 
        //헤더를 확인할 커스텀 필터 추가 .. (2)
        http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
 
        //토큰 재발급 URL은 인증 없이 접근 가능하도록 설정. 나머지 API URL은 인증 필요 .. (3)
        http.authorizeHttpRequests()
                .requestMatchers("/api/token").permitAll()
                .requestMatchers("/api/**").authenticated()
                .anyRequest().permitAll();
 
        http.oauth2Login()
                .loginPage("/login")
                .authorizationEndpoint()
                //Authorization 요청과 관련된 상태 저장 ..(4)
                .authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository())
                .and()
                .successHandler(oAuth2SuccessHandler()) // 인증 성공 시 실행할 핸들러 ..(5)
                .userInfoEndpoint()
                .userService(oAuth2UserCustomService);
 
        http.logout()
                .logoutSuccessUrl("/login");
 
        // /api로 시작하는 url인 경우 401 상태 코드를 반환하도록 예외 처리 .. (60
        http.exceptionHandling()
                .defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
                        new AntPathRequestMatcher("/api/**"));
        return http.build();
    }
 
    @Bean
    public OAuth2SuccessHandler oAuth2SuccessHandler(){
        return new OAuth2SuccessHandler(tokenProvider, refreshTokenRepository,
                oAuth2AuthorizationRequestBasedOnCookieRepository(),
        userService);
    }
 
    @Bean
    public TokenAuthenticationFilter tokenAuthenticationFilter(){
        return new TokenAuthenticationFilter(tokenProvider);
    }
 
    @Bean
    public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestBasedOnCookieRepository(){
        return new OAuth2AuthorizationRequestBasedOnCookieRepository();
    }
 
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
 
cs

config > WebOAuthSecurityConfig.java 파일을 새롭게 생성한다.

  • filterChain() : 토큰 방식으로 인증 하므로 기존 폼 로그인, 세션 기능을 비활성화
  • addFilterBefore() : 헤더 값을 확인 할 커스텀 필드 추가
  • authorizationRequests() : 토큰 재발급 URL은 인증 없이 접근하도록 설정하고 나머지 API들은 모두 인증해야 접근하도록 설정
  • oauth2Login() : 필요한 정보를 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를 저장할 저장소를 설정. 인증 성공 시 실행할 핸들러를 설정한다.
  • exceptionHandling() : /api로 시작하는 url인 경우 인증 실패 시 401 상태 코드 즉, Unathorized를 반환

 

 

OAuth2AuthorizationRequestBasedOnCookieRequestRepository.java

OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를 저장할 저장소

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
package study.springbootdeveloper.config.oauth;
 
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.web.util.WebUtils;
import study.springbootdeveloper.util.CookieUtil;
 
public class OAuth2AuthorizationRequestBasedOnCookieRepository implements
        AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
 
    public final static String OAUTH2_AUTHORIZATION_REQEUST_COOKIE_NAME = "oauth2_auth_request";
    private final static int COOKIE_EXPIRE_SECONDS = 18000;
 
    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request,
                                                                 HttpServletResponse response) {
        return this.loadAuthorizationRequest(request);
    }
 
    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        Cookie cookie = WebUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQEUST_COOKIE_NAME);
        return CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class);
    }
 
    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest,
                                         HttpServletRequest request, HttpServletResponse response){
        if(authorizationRequest == null){
            removeAuthorizationRequestCookies(request, response);
            return;
        }
        CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQEUST_COOKIE_NAME,
                CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
    }
 
    public void removeAuthorizationRequestCookies(HttpServletRequest request,
                                                  HttpServletResponse response){
        CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQEUST_COOKIE_NAME);
    }
}
 
cs

 

 

UserService

인증 성공 시 실행할 핸들러 구현을 위해 userService를 수정해야 한다.

 

 

 

OAuth2SuccessHandler.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
package study.springbootdeveloper.config.oauth;
 
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import study.springbootdeveloper.BlogService.UserService;
import study.springbootdeveloper.config.jwt.TokenProvider;
import study.springbootdeveloper.domain.RefreshToken;
import study.springbootdeveloper.domain.User;
import study.springbootdeveloper.repository.RefreshTokenRepository;
import study.springbootdeveloper.util.CookieUtil;
 
import java.io.IOException;
import java.time.Duration;
 
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";
    public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14);
    public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1);
    public static final String REDIRECT_PATH = "/articles";
 
    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
    private final UserService userService;
 
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        User user = userService.findByEmail((String) oAuth2User.getAttributes().get("email"));
 
        //리프레시 토큰 생성 -> 저장 -> 쿠키에 저장 ..(1)
        String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION);
        saveRefreshToken(user.getId(), refreshToken);
        addRefreshTokenToCookie(request, response, refreshToken);
 
        //액세스 토큰 생성 -> 패스에 액세스 토큰을 추가 .. (2)
        String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION);
        String targetUrl = getTargetUrl(accessToken);
 
        //인증 관련 설정 값, 쿠키 제거 .. (3)
        clearAuthenticationAttributes(request, response);
 
        //리다이렉트 .. (4)
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
 
    //생성된 리프레시 토큰을 전달받아 데이터베이스에 저장
    private void saveRefreshToken(Long userId, String newRefreshToken){
        RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId)
                .map(entity -> entity.update(newRefreshToken))
                .orElse(new RefreshToken(userId, newRefreshToken));
 
        refreshTokenRepository.save(refreshToken);
    }
 
    //생성된 리프레시 토큰을 쿠키에 저장
    private void addRefreshTokenToCookie(HttpServletRequest request,
                                         HttpServletResponse response, String refreshToekn){
        int cookieMaxAge = (int) REFRESH_TOKEN_DURATION.toSeconds();
        CookieUtil.deleteCookie(request, response, REFRESH_TOKEN_COOKIE_NAME);
        CookieUtil.addCookie(response, REFRESH_TOKEN_COOKIE_NAME, refreshToekn, cookieMaxAge);
    }
 
    // 인증 관련 설정 값, 쿠키 제거
    private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response){
        super.clearAuthenticationAttributes(request);
        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
    }
 
    //액세스 토큰을 패스에 추가
    private String getTargetUrl(String token){
        return UriComponentsBuilder.fromUriString(REDIRECT_PATH)
                .queryParam("token", token)
                .build()
                .toUriString();
    }
}
 
cs

스프링 시큐리티는 별도의 핸들러를 지정하지 않으면 로그인 성공 이후 SimpleUrlAuthenticationSuccessHandler을 사용한다.
일반적인 기능은 동일하고 토큰과 관련된 추가작업을 위해 오버라이드를 사용하여 함수를 정의한다.

  • (1) : 토큰 제공자를 사용해 리프레시 토큰을 만든 뒤, saveRefresthToken() 메서드를 호출해 리프레시 토큰을 데이터베이스에 유저 아이디와 저장. 그 후, 액세스 토큰이 만료되면 재발급 요청하도록 addRefreshTokenToCookie() 메서드를 호출해 쿠키에 리프레시 토큰을 저장
  • (2) : 토큰 제공자를 사용해 액세스 토큰을 만든 뒤, 리다이렉트 경로가 담긴 값을 가져와 쿼리 파라미터에 액세스 토큰을 추가한다.
  • (3) : 인증 프로세스를 진행하면서 세션과 쿠키에 임시로 저장해둔 인증 관련 데이터를 제거한다. 기본적으로 제공하는 clearAuthenticationAttributes()는 그대로 호출하고 removeAuthorizationReqeustCookies()를 추가 호출해 OAuth 인증을 위해 저장된 정보도 삭제한다.
  • (4) : (2)에서 만든 URL로 리다이렉트

 

 

 


글에 글쓴이 추가

article.java 도메인에 글쓴이 필드를 추가한다.

 

author 필드가 추가됨으로써 수정되어야 할 부분들 수정

AddArticleRequest DTO 에 해당 필드값을 추가해준다.
BlogService.java에서 글쓴이를 같이 리포지포리로 직렬화해서 넘겨준다.
현재 인증 정보를 가져오는 principal객체를 파라미터로 추가한다. 인증 객체에서 유저이름을 가져와서 서비스단으로 넘겨준다.
글 상세 페이지에서도 글쓴이가 보이도록 필드를 축해준다.

 

 

data.sql에도 데이터를 추가해준다.

 

 


뷰 구성하기

뷰 컨트롤러에 오어스 로그인 페이지를 리턴해준다.

 

 

resources/static/img 경로에 구글 아이콘을 생성해준다.

 

token.js / article.js 에 관련된 함수를 작성

article.js

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
// 삭제 기능
const deleteButton = document.getElementById('delete-btn');
 
if (deleteButton) {
    deleteButton.addEventListener('click'event => {
        let id = document.getElementById('article-id').value;
        function success() {
            alert('삭제가 완료되었습니다.');
            location.replace('/articles');
        }
 
        function fail() {
            alert('삭제 실패했습니다.');
            location.replace('/articles');
        }
 
        httpRequest('DELETE',`/api/articles/${id}`, null, success, fail);
    });
}
 
// 수정 기능
const modifyButton = document.getElementById('modify-btn');
 
if (modifyButton) {
    modifyButton.addEventListener('click'event => {
        let params = new URLSearchParams(location.search);
        let id = params.get('id');
 
        body = JSON.stringify({
            title: document.getElementById('title').value,
            contentdocument.getElementById('content').value
        })
 
        function success() {
            alert('수정 완료되었습니다.');
            location.replace(`/articles/${id}`);
        }
 
        function fail() {
            alert('수정 실패했습니다.');
            location.replace(`/articles/${id}`);
        }
 
        httpRequest('PUT',`/api/articles/${id}`, body, success, fail);
    });
}
 
// 생성 기능
const createButton = document.getElementById('create-btn');
 
if (createButton) {
    // 등록 버튼을 클릭하면 /api/articles로 요청을 보낸다
    createButton.addEventListener('click'event => {
        body = JSON.stringify({
            title: document.getElementById('title').value,
            contentdocument.getElementById('content').value
        });
        function success() {
            alert('등록 완료되었습니다.');
            location.replace('/articles');
        };
        function fail() {
            alert('등록 실패했습니다.');
            location.replace('/articles');
        };
 
        httpRequest('POST','/api/articles', body, success, fail)
    });
}
 
 
// 쿠키를 가져오는 함수
function getCookie(key) {
    var result = null;
    var cookie = document.cookie.split(';');
    cookie.some(function (item) {
        item = item.replace(' ''');
 
        var dic = item.split('=');
 
        if (key === dic[0]) {
            result = dic[1];
            return true;
        }
    });
 
    return result;
}
 
// HTTP 요청을 보내는 함수
function httpRequest(method, url, body, success, fail) {
    fetch(url, {
        method: method,
        headers: { // 로컬 스토리지에서 액세스 토큰 값을 가져와 헤더에 추가
            Authorization: 'Bearer ' + localStorage.getItem('access_token'),
            'Content-Type''application/json',
        },
        body: body,
    }).then(response => {
        if (response.status === 200 || response.status === 201) {
            return success();
        }
        const refresh_token = getCookie('refresh_token');
        if (response.status === 401 && refresh_token) {
            fetch('/api/token', {
                method: 'POST',
                headers: {
                    Authorization: 'Bearer ' + localStorage.getItem('access_token'),
                    'Content-Type''application/json',
                },
                body: JSON.stringify({
                    refreshToken: getCookie('refresh_token'),
                }),
            })
                .then(res => {
                    if (res.ok) {
                        return res.json();
                    }
                })
                .then(result => { // 재발급이 성공하면 로컬 스토리지값을 새로운 액세스 토큰으로 교체
                    localStorage.setItem('access_token', result.accessToken);
                    httpRequest(method, url, body, success, fail);
                })
                .catch(error => fail());
        } else {
            return fail();
        }
    });
}
cs

 

 

token.js

 

 

BlogService.java 파일에서 본인 글이 아닌데 수정, 삭제를 시도하는 경우, 제외처리를 하도록 수정한다.

 

 

로컬호스트 접속시 !

 

 

oauth를 통해 로그인을 하게 되고, 개발자도구에서 application > Storage > local storage를 보게 되면 토큰이 발급된 걸 볼 수 있다.

 

쿠키에 리프레시 토큰도 잘 확인된다.

 

 

액세스 토큰이 없어서 실패하면, 다시 액세스 토큰을 발급받아 인증 요청