본문 바로가기

SpringBoot

[스프링 부트 3 백엔드 개발자 되기] ch 10. OAuth2로 로그인/로그아웃 구현하기

1. OAuth

 

- 제 3의 서비스에 계정 관리를 맡기는 방식 

- 흔히 볼 수 있는 네이버로 로그인하기, 구글로 로그인하기 같은 방법 

- OAuth를 사용하면 인증 서버에서 발급받은 토큰을 사용해서 리소스 서버에 리소스 오너의 정보를 요청하고 응답받아 사용할 수 있다. 

 

리소스 오너(resource owner)

- 인증 서버에 자신의 정보를 사용하도록 허가하는 주체 

- 서비스를 이용하는 사용자 

 

리소스 서버(resource server)

- 리소스 오너의 정보를 가지며, 리소스 오너의 정보를 보호하는 주체 

- 네이버, 구글, 페이스북이 리소스 서버에 해당 

 

인증 서버(authorization server)

- 클라이언트에게 리소스 오너의 정보에 접근할 수 있는 토큰을 발급하는 역할을 하는 애플리케이션

 

클라이언트 애플리케이션(client application)

- 인증 서버에게 인증을 받고 리소스 오너의 리소스를 사용하는 주체 

 

 

리소스 오너 정보를 취득하는 4가지 방법

권한 부여 코드 승인 타입
(authorization code grant type)
클라이언트가 리소스에 접근하는 데 사용하며, 권한에 접근할 수 있는 코드와 리소스 오너에 대한 액세스 토큰을 발급받는 방식
암시적 승인 타입
(implicit grant type)
서버가 없는 자바스크립트 웹 애플리케이션 클라이언트에서 주로 사용하는 방법. 클라이언트가 요청을 보내면 리소스 오너의 인증 과정 이외에는 권한 코드 교환 등의 별다른 인증 과정을 거치지 않고 액세스 토큰을 제공받는 방식 
리소스 소유자 암호 자격증명 승인 타입
(resource owner password credentials)
클라이언트의 패스워드를 이용해서 액세스 토큰에 사용자의 자격 증명을 교환하는 방식
클라이언트 자격증명 승인 타입
(client credentials grant)
클라이언트가 컨텍스트 외부에서 액세스 토큰을 얻어 특정 리소스에 접근을 요청할 때 사용하는 방식 

 

 

1) 권한 부여 코드 승인 타입 

 

 

권한 요청 

 

- 클라이언트, 즉, 스프링 부트 서버가 특정 사용자 데이터에 접근하기 위해 권한 서버, 즉 , 카카오나 구글 권한 서버에 요청을 보내는 것

- 요청 URI는 권한 서버마다 다르지만 보통은 클라이언트 ID, 리다이렉트 URI, 응답 타입 등을 파라미터로 보낸다. 

 

ex) 권한 요청을 위한 파라미터 

GET spring-authorization-server.example/authorize?
    client_id=66a36b4c2d& //인증 서버가 클라이언트에 할당한 고유 식별자 
    redirect_uri=http://localhost:8080/myapp& //로그인 시 이동해야하는 URI
    response_type=code& //클라이언트가 제공받길 원하는 응답 타입
    scope=profile //제공받고자 하는 리소스 오너의 정보 목록

 

 

데이터 접근용 권한 부여 

 

- 인증 서버에 요청을 처음 보내는 경우 사용자에게 보이는 페이지를 로그인 페이지로 변경하고 사용자의 데이터에 접근 동의를 얻는다. 이 과정을 최초 1회만 진행된다. 이후에는 인증 서버에서 동의 내용을 저장하고 있기 때문에 로그인만 진행된다. 로그인이 성공되면 권한 부여 서버는 데이터에 접근할 수 있게 인증 및 권한 부여를 수신한다. 

 

인증 코드 제공

 

- 사용자가 로그인에 성공하면 권한 요청 시에 파라미터로 보낸 redirect_uri로 리다이렉션 된다. 이때 파라미터에 인증코드를 함께 제공한다. 

GET http://localhost:8080/myapp?code=a1s2f3mcj2

 

 

액세스 토큰 응답

 

- 인증 코드를 받으면 액세스 토큰으로 교환해야 한다. 액세스 토큰은 로그인 세션에 대한 보안 자격을 증명하는 식별 코드를 의미한다. 

 

ex)  /token POST 요청 

POST spring-authorization-server.example.com/token
{
    "client_id":"66a36b4c2",
    "client_secret":"aabb11dd44", //OAuth 서비스에 등록할 때 제공받는 비밀키 
    "redirect_uri":"http://localhost:8080/myapp",
    //권한 유형 확인
    //권한 서버는 요청 값을 기반으로 유효한 정보인지 확인하고, 유효한 정보라면 액세스 토큰으로 응답 
    "grant_type":"authorization_code", 
    "code":"a1b2c3d4e5f6g7h8"
}

 

 

액세스 토큰으로 API 응답 & 반환 

 

- 제공받은 액세스 토큰으로 리소스 오너의 정보를 가져올 수 있다. 

- 정보가 필요할 때마다 API 호출을 통해 정보를 가져오고 리소스 서버는 토큰이 유효한지 검사한 뒤에 응답한다. 

 

 

2) 쿠키

 

- 사용자가 어떠한 웹사이트를 방문했을 때 해당 웹사이트의 서버에서 로컬 환경에 저장하는 작은 데이터 

- 이 값이 있기 때문에 이전에 방문한 적이 있는지 알 수 있고, 이전에 로그인을 했다면 로그인 정보도 유지할 수 있다. 

- 쿠키는 키와 값으로 이루어져 있으며 만료 기간, 도메인 등의 정보를 가지고 있다. 

- HTTP 요청을 통해 쿠키의 특정 키에 값을 추가할 수 있다. 

 

 

 

- 클라이언트가 정보를 요청하면 서버에서 정보를 값으로 넣은 쿠키를 생성해서 요청한 정보, 즉 HTTP 헤더와 함께 돌려보낸다. 

- 그러면 클라이언트는 로컬 즉, 브라우저에 쿠키를 저장한다. 

- 이후 사이트를 재방문할 때는 사용자가 로컬 환경에 있는 쿠키와 함께 서버에 요청한다. 

- 이렇게 하면 클라이언트에 값을 저장할 수 있기 때문에 현재 사용자 관련 정보를 보여줄 수 있다. 

 

 

2. 토큰 발급받기 

 

- 구글 로그인 기능을 추가하기 위해 인증 서버에게 토큰 제공받기 

 

구글 클라우드 콘솔 

https://cloud.google.com/cloud-console?hl=ko

 

Google Cloud 콘솔  |  Cloud Console - 웹 UI 관리

콘솔에서 데이터 분석, VM, 데이터 스토어, 데이터베이스, 네트워킹, 개발자 서비스 등 클라우드 애플리케이션의 모든 것을 관리하세요.

cloud.google.com

 

- 사용자 인증 정보를 만들려면 OAuth 동의 화면을 먼저 구성해야 한다. 

- User Type을 외부용으로 선택한 후 [만들기] 버튼을 클릭한다. 

 

 

 

- OAuth 설정값을 스프링 부트 애플리케이션 설정 파일에서 사용하기 위해 applicaqtion.yml 파일을 열고 다음 내용을 추가 

 

application.yml

  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 
            client-secret: 
            scope:
              -email
              -profile

 

 

3. 스프링 시큐리티로 OAuth2를 구현하고 적용하기 

 

- 쿠키 관리 클래스를 구현하고, OAuth2에서 제공받은 인증 객체로 사용자 정보를 가져오는 역할을 하는 서비스를 구현 

- OAuth2 설정 파일 구현 

- 직접 테스트하도록 뷰를 구성 

 

1) 의존성 추가하기 

 

build.gradle

...
//OAuth2를 사용하기 위한 스타터 추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

 

 

2) 쿠키 관리 클래스 구현하기 

 

- 유틸리티로 사용할 쿠키 관리 클래스를 구현 

 

util/CookieUtil.java

public class CookieUtil {

    //요청값(이름,값,만료 기간)을 바탕으로 쿠키 추가 
    public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
        Cookie cookie = new Cookie(name, value);
        cookie.setPath("/");
        cookie.setMaxAge(maxAge);

        response.addCookie(cookie);
    }

    //쿠키의 이름을 입력받아 쿠키 삭제 
    //파라미터로 넘어온 키의 쿠키를 빈 값으로 바꾸고 만료 시간을 0으로 설정해 쿠키가 재생성 되지마자 만료 처리 
    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
        Cookie[] cookies = request.getCookies();

        if (cookies == null) {
            return;
        }

        for (Cookie cookie : cookies) {
            if (name.equals(cookie.getName())) {
                cookie.setValue("");
                cookie.setPath("/");
                cookie.setMaxAge(0);
                response.addCookie(cookie);
            }
        }
    }

    //객체를 직렬화해 쿠키의 값으로 변환 
    public static String serialize(Object obj) {
        return Base64.getUrlEncoder()
                .encodeToString(SerializationUtils.serialize(obj));
    }

    //쿠키를 역직렬화해 객채로 변환 
    public static <T> T deserialize(Cookie cookie, Class<T> cls) {
        return cls.cast(
                SerializationUtils.deserialize(
                        Base64.getUrlDecoder().decode(cookie.getValue())
                )
        );
    }
}

 

 

3) OAuth2 서비스 구현하기 

 

- 사용자 정보를 조회해 users 테이블에 사용자 정보가 있다면 리소스 서버에서 제공해주는 이름을 업데이트 

- 사용자 정보가 없다면 users 테이블에 새 사용자를 생성해 데이터베이스에 저장하는 서비스 구현 

 

domain/User.java

- 사용자 이름 추가 

//사용자 이름
    @Column(name="nickname", unique=true)
    private String nickname;

    @Builder
    public User(String email, String password, String nickname) {
        this.email = email;
        this.password = password;
        this.nickname = nickname;
    }
    
    ....
    
    //사용자 이름 변경
    public User update(String nickname) {
        this.nickname = nickname;

        return this;
    }

 

 

config/oauth/OAuth2UserCustomService.java

 

- 리소스 서버에서 보내주는 사용자 정보를 불러오는 메소드인 loadUser()를 통해 사용자를 조회하고, users 테이블에 사용자 정보가 있다면 이름을 업데이트하고 없다면 saveOrUpdate() 메서드를 실행해 users 테이블에 회원 데이터를 추가 

 

@RequiredArgsConstructor
@Service
public class OAuth2UserCustomService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        
        //요청을 바탕으로 유저 정보를 담은 객체 반환 
        OAuth2User user = super.loadUser(userRequest);
        saveOrUpdate(user);

        return user;
    }

    
    //유저가 있으면 업데이트, 없으면 유저 생성 
    private User saveOrUpdate(OAuth2User oAuth2User) {
        Map<String, Object> attributes = oAuth2User.getAttributes();

        String email = (String) attributes.get("email");
        String name = (String) attributes.get("name");

        User user = userRepository.findByEmail(email)
                .map(entity -> entity.update(name))
                .orElse(User.builder()
                        .email(email)
                        .nickname(name)
                        .build());

        return userRepository.save(user);
    }
}

 

- 부모 클래스인 DefaultOAuth2UserService에서 제공하는 OAuth 서비스에서 제공하는 정보를 기반으로 유저 객체를 만들어주는 loadUser() 메서드를 사용해 사용자 객체를 불러온다. 사용자 객체는 식별자, 이름, 이메일, 프로필 사진 링크 등의 정보를 담고 있다. 

 

- saveOrUpdate() 메서드는 사용자가 user 테이블에 있으면 업데이트하고 없으면 사용자를 새로 생성해서 데이터베이스에 저장한다. 

 

 

4) OAuth2 설정 파일 작성하기 

 

- OAuth2.JWT에 알맞게 설정 파일을 수정 

 

config/WebOAuthSecurityConfig.java

@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(
                        new AntPathRequestMatcher("/img/**"),
                        new AntPathRequestMatcher("/css/**"),
                        new AntPathRequestMatcher("/js/**")
                );
    }




    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        //토큰 방식으로 인증을 하기 때문에 기존에 사용하던 폼 로그인, 세션 비활성화 
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .logout(AbstractHttpConfigurer::disable)
                .sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                //헤더를 확인할 커스텀 필터 추가 
                .addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                //토큰 재발급 URL은 인증없이 접근 가능하도록 설정. 나머지 API URL은 인증 필요 
                .authorizeRequests(auth -> auth
                        .requestMatchers(new AntPathRequestMatcher("/api/token")).permitAll()
                        .requestMatchers(new AntPathRequestMatcher("/api/**")).authenticated()
                        .anyRequest().permitAll())
                .oauth2Login(oauth2 -> oauth2
                        .loginPage("/login")
                        //Authorization 요청과 관련된 상태 저장
                        .authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository()))
                        .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(oAuth2UserCustomService))
                        //인증 성공 시 실행할 핸들러 
                        .successHandler(oAuth2SuccessHandler())
                )
                
                // /api로 시작하는 url인 경우 401 상태 코드를 반환하도록 예외 처리 
                .exceptionHandling(exceptionHandling -> exceptionHandling
                        .defaultAuthenticationEntryPointFor(
                                new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
                                new AntPathRequestMatcher("/api/**")
                        ))
                .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();
    }
}

 

 

OAuth2AuthorizationRequestBasedOnCookieRepository.java

 

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

- 권한 인증 흐름에서 클라이언트의 요청을 유지하는 데 사용하는 AuthorizationRequestRepository 클래스를 구현해 쿠키를 사용해 OAuth의 정보를 가져오고 저장하는 로직 작성 

public class OAuth2AuthorizationRequestBasedOnCookieRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
    public final static String OAUTH2_AUTHORIZATION_REQUEST_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_REQUEST_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_REQUEST_COOKIE_NAME,
                CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
    }

    public void removeAuthorizationRequestCookies(HttpServletRequest request,
                                                  HttpServletResponse response) {
        CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
    }
}

 

 

UserService.java

 

- 인증 성공 시 실행할 핸들러 구현 

- BCryptPasswordEncoder를 삭제하고 BCryptPasswordEncoder를 생성자로 사용해 직접 생성해서 패스워드를 암호화할 수 있게 코드를 수정한 다음 findByEmail() 메서드를 추가 

 

@RequiredArgsConstructor
@Service
public class UserService {
    private final UserRepository userRepository;

    public Long save(AddUserRequest dto) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        return userRepository.save(User.builder()
                .email(dto.getEmail())
                .password(encoder.encode(dto.getPassword()))
                .build()).getId();
    }

    public User findById(Long userId) {
        return userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
    }

    public User findByEmail(String email) {
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
    }
}

 

 

config/oauth/OAuth2SuccesssHandler.java

@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"));

        //리프레시 토큰 생성 -> 저장 -> 쿠키에 저장
        //토큰 제공자를 사용해 리프레시 토큰을 만든 뒤에, saveRefreshToken() 메서드를 호출해 
        //리프레시 토큰을 데이터베이스에 유저 아이디와 함께 저장 
        //그 이후에는 클라이언트에서 액세스 토큰이 만료되면 재발급 요청하도록 addRefreshTokenToCookie() 메서드를 호출해 쿠키에 리프레시 토큰 저장 
        String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION);
        saveRefreshToken(user.getId(), refreshToken);
        addRefreshTokenToCookie(request, response, refreshToken);

        String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION);
        String targetUrl = getTargetUrl(accessToken);

        clearAuthenticationAttributes(request, response);

        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 refreshToken) {
        int cookieMaxAge = (int) REFRESH_TOKEN_DURATION.toSeconds();

        CookieUtil.deleteCookie(request, response, REFRESH_TOKEN_COOKIE_NAME);
        CookieUtil.addCookie(response, REFRESH_TOKEN_COOKIE_NAME, refreshToken, 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();
    }
}

 

 

5) 글에 글쓴이 추가하기 

 

 Article.java

 

- author 변수를 추가 

- 빌더 패턴에서도 author를 추가해 객체를 생성할 때 글쓴이를 입력받을 수 있게 변경 

 

@Column(name = "author", nullable = false)
    private String author;

    @Builder
    public Article(String author, String title, String content) {
        this.author = author;
        this.title = title;
        this.content = content;
    }

 

dto/AddArticleRequest.java

 

- 기존 글을 작성하는 API에서 작성자를 추가로 저장하기 위해 toEntity() 메서드를 수정해 author 값도 추가 저장하도록 변경 

public Article toEntity(String author) { //생성자를 사용해 객체 생성
        return Article.builder()
                .title(title)
                .content(content)
                .author(author)
                .build();
    }

 

 

BlogService.java

- save() 메서드에서 유저 이름을 추가로 입력받고 toEntity()의 인수를 전달받은 유저 이름을 반환하도록 코드를 수정 

public Article save(AddArticleRequest request, String userName) {
        return blogRepository.save(request.toEntity(userName));
    }

 

BlogApiController.java

- 현재 인증 정보를 가져오는 principal 객체를 파라미터로 추가 

- 인증 객체에서 유저 이름을 가져온 뒤 save() 메서드로 넘겨줌 

...
@PostMapping("/api/articles")
    public ResponseEntity<Article> addArticle(@RequestBody AddArticleRequest request, Principal principal) {
        Article savedArticle = blogService.save(request, principal.getName());
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(savedArticle);
}
...

 

 

ArticleViewResponse.java

- 글 상세 페이지에서도 글쓴이의 정보가 보여야 하므로 author 필드 추가 

 

private Long id;
    private String title;
    private String content;
    private LocalDateTime createdAt;
    private String author;

    public ArticleViewResponse(Article article) {
        this.id = article.getId();
        this.title = article.getTitle();
        this.content = article.getContent();
        this.createdAt = article.getCreatedAt();
        this.author = article.getAuthor();
    }

 

data.sql

- author 컬럼 추가 

INSERT INTO article (title, content, author, created_at, updated_at) VALUES ('제목1', '내용1', 'user1', NOW(), NOW())
INSERT INTO article (title, content, author, created_at, updated_at) VALUES ('제목2', '내용2', 'user2', NOW(), NOW())
INSERT INTO article (title, content, author, created_at, updated_at) VALUES ('제목3', '내용3', 'user3', NOW(), NOW())

 

 

6) OAuth 뷰 구성하기 

 

UserViewController.java

- login() 메서드의 뷰를 oauthLogin으로 변경 

...
@GetMapping("/login")
public String login() {
    return "oauthLogin";
}
...

 

 

oauthLogin.html

- 로그인 화면에 OAuth 연결 버튼을 생성

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">

    <style>
        .gradient-custom {
            background: #6a11cb;
            background: -webkit-linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1));
            background: linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1))
        }
    </style>
</head>
<body class="gradient-custom">
    <section class="d-flex vh-100">
        <div class="container-fluid row justify-content-center align-content-center">
            <div class="card bg-dark" style="border-radius: 1rem;">
                <div class="card-body p-5 text-center">
                    <h2 class="text-white">LOGIN</h2>
                    <p class="text-white-50 mt-2 mb-5">서비스 사용을 위해 로그인을 해주세요!</p>

                    <div class = "mb-2">
                        <a href="/oauth2/authorization/google">
                            <img src="/img/google.png">
                        </a>
                    </div>
                </div>
            </div>
        </div>
    </section>
</body>
</html>

 

 

token.js

- 파라미터로 받은 토큰이 있다면 토큰을 로컬 스토리지에 저장 

const token = searchParam('token')

if (token) {
    localStorage.setItem("access_token", token)
}

function searchParam(key) {
    return new URLSearchParams(location.search).get(key);
}

 

articleList.html

- token.js를 가져올 수 있도록 파일을 수정 

...
<script src="/js/token.js"></script>
<script src="/js/article.js"></script>

 

article.js

 

- 기존 createButton 관련 코드를 수정 

- POST 요청을 보낼 때 액세스 토큰도 함께 보낸다. 

- 만약에 응답에 권한이 없다는 에러 코드가 발생하면 리프레시 토큰과 함께 새로운 액세스 토큰을 요청하고, 전달받은 액세스 토큰으로 다시 API를 요청 

 

// 삭제 기능
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,
            content: document.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,
            content: document.getElementById('content').value
        });
        function success() {
            alert('등록 완료되었습니다.');
            location.replace('/articles');
        };
        function fail() {
            alert('등록 실패했습니다.');
            location.replace('/articles');
        };

        httpRequest('POST','/api/articles', body, success, fail)
    });
}


// 로그아웃 기능
const logoutButton = document.getElementById('logout-btn');

if (logoutButton) {
    logoutButton.addEventListener('click', event => {
        function success() {
            // 로컬 스토리지에 저장된 액세스 토큰을 삭제
            localStorage.removeItem('access_token');

            // 쿠키에 저장된 리프레시 토큰을 삭제
            deleteCookie('refresh_token');
            location.replace('/login');
        }
        function fail() {
            alert('로그아웃 실패했습니다.');
        }

        httpRequest('DELETE','/api/refresh-token', null, 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;
}

// 쿠키를 삭제하는 함수
function deleteCookie(name) {
    document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:01 GMT;';
}


// 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();
        }
    });
}

 

 

7) 글 수정, 삭제, 글쓴이 확인 로직 추가하기 

 

BlogService.java

 

- 글을 수정하거나 삭제할 때 요청 헤더에 토큰을 전달하므로 사용자 자신이 작성한 글인지 검증할 수 있다. 

- 따라서 본인 글이 아닌데 수정, 삭제를 시도하는 경우에 예외를 발생하도록 코드를 수정 

 

@RequiredArgsConstructor
@Service
public class BlogService {

    private final BlogRepository blogRepository;

    public Article save(AddArticleRequest request, String userName) {
        return blogRepository.save(request.toEntity(userName));
    }

    public List<Article> findAll() {
        return blogRepository.findAll();
    }

    public Article findById(long id) {
        return blogRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("not found : " + id));
    }

    public void delete(long id) {
        Article article = blogRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("not found : " + id));

        authorizeArticleAuthor(article);
        blogRepository.delete(article);
    }

    @Transactional
    public Article update(long id, UpdateArticleRequest request) {
        Article article = blogRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("not found : " + id));

        authorizeArticleAuthor(article);
        article.update(request.getTitle(), request.getContent());

        return article;
    }

    private static void authorizeArticleAuthor(Article article) {
        String userName = SecurityContextHolder.getContext().getAuthentication().getName();
        if (!article.getAuthor().equals(userName)) {
            throw new IllegalArgumentException("not authorized");
        }
    }

}