본문 바로가기

spring

SpringBoot JWT에 대해서

이번 포스팅에서는 이전에 시큐리티를 적용한 것에서 이어서 JWT를 추가해 진행해보려 한다.

필자의 다른 글에 시큐리티와 jwt를 적용한 프로젝트는 있지만 이번 jwt에 대해 따로 알아본 적은 없기에 JWT에 대해서 알아보고 예제를 진행하며 알아보도록 하자. 

 

JWT란?

Jason Web Token으로 두 개체에서 JSON 객체를 사용해 정보를 안전하게 전송하기 위한 방법이다.

JWT를 이용하면 웹 서버가 유저에 대한 정보를 기억할 필요 없이 토큰이 유효한지 확인하면 된다.

 

토큰 기반 인증에 대해 알아보자.

스프링 시큐리티에서는 기본적으로 세션 기반 인증을 제공해준다. 세션에 사용자 정보를 담아 생성하고 저장해 인증을 진행했지만 토큰 기반은 토큰을 사용해서 인증을 진행한다. 토큰은 서버에서 클라이언트를 구분하기 위한 유일한 값인데 서버가 토큰을 생성해서 클라이언트에게 제공하면 클라이언트는 이 토큰을 가지고 있다가 여러 요청을 이 토큰과 함께 요청한다. 서버는 이 토큰을 보고 유효한 사용자인지 검증한다.

 

토큰을 전달하고 인증받는 과정을 확인해보자.

 

위와 같은 과정을 거쳐 토큰 기반 인증을 진행하게 된다.

토큰 기반 인증의 특징을 확인해 보자.

특징은 무상태성, 확장성, 무결성의 특징이 있다.

 

무상태성

사용자의 인증 정보가 담겨 있는 토큰이 서버가 아닌 클라이언트가 가지고 있고 서버에 저장할 필요가 없다.

세션처럼 진행할 땐 서버가 데이터를 유지하기 때문에 서버의 자원을 소비하지만 토큰 기반 인증은 클라이언트에서는 인증 정보가 담긴 토큰을 생성하고 인증한다. 클라이언트에서는 사용자의 인증 상태를 유지하며 이후 요청을 처리해야 하는데 이를 상태를 관리한다고 한다.

서버 입장에서는 클라이언트의 인증 정보를 저장하거나 유지하지 않아도 되기 때문에 완전한 무상태로 효율적인 검증을 할 수 있다.

 

확장성

무상태성은 확장성에 영향을 준다. 서버를 확장할 때 상태 관리를 신경 쓸 필요가 없으니 서버 확장에도 용이하다.

만약 여러 서버가 있다 가정하면 세션 기반 인증은 각각 API에서 인증해야 하지만 토큰 기반 인증은 토큰을 가진 주체가 서버가 아닌 클라이언트이기 때문에 가지고 있는 하나의 토큰으로 여러 서버에 요청을 보낼 수 있다. 추가로 소셜 로그인과 같이 토큰 기반 인증을 사용하는 다른 시스템에 접근해 로그인 방식을 확장할 수 있고 이를 활용해 다른 서비스에 권한을 공유할 수 있다.

 

무결성

토큰 방식은 HMAC(hash-based message authentication) 기법이라 부르는데 토큰을 발급한 이후에는 토큰 정보를 변경하는 행위를 할 수 없다. 이는 토큰의 무결성을 보장하고 만약 토큰이 조금이라도 변경된다면 서버에서 유효하지 않은 토큰이라 판단한다.

 

다음으로는 JWT의 구조를 확인해 보자.

JWT는 세 부분으로 구성되어 있다.

헤더(header), 페이로드(payload), 시그니처(signature)

이는 hhhh.pppp.ssss 이런 방식으로 구성되어 있다.

 

헤더는 JWT임을 명시하고 사된 암호화 알고리즘 정보가 담겨 있다.

페이로드는 토큰의 본문에 해당되고 토큰을 발급받는 대상에 대한 정보나 권한 등이 담겨있다.

시그니처는 시그니처를 통해 토큰의 무결성을 검증 가능하다.

 

페이로드에는 내용이 담기게 되는데 내용에는 토큰에 관련된 정보를 담는다. 내용의 한 덩어리를 클레임(claim)이라 부르며 클레임은 키와 값인 한 쌍으로 이루어져 있다. 등록된 클레임, 공개 클레임, 비공개 클레임으로 나눌 수 있다.

클레임에는 어떤 정보가 담겨 있는지 알아보자.

등록된 클레임은 토큰에 대한 정보를 담는 데 사용된다.

이름 설명
iss 토큰 발급자
sub 토큰 제목
aud 토큰 대상자
exp 토큰의 만료 시간. 시간은 NumericDate 형식으로 항상 현재 시간 이후로 설정한다.
nbf 토큰 활성 날짜와 비슷한 개념으로 NotBefore를 의미한다 NumericDate 형식으로 날짜를 지정하고 이 날짜가 지나기 전까지 토큰이 처리되지 않는다.
iat 토큰이 발급된 시간이다.
jti JWT의 고유 식별자로 주로 일회용 토큰에 사용한다.

 

공개 클레임은 공개되어도 상관없는 클레임을 의미한다 충돌을 방지할 수 있는 이름을 가져야 하며 보통 클레임 이름을 URI로 짓는다.

비공개 클레임은 공개되면 안 되는 클레임을 의미한다.

{
	"iss": "xxx@xxx",//등록된 클레임
    "iat": 16161616,//등록된 클레임
    "exp": 16161625,//등록된 클레임
    "https://ex.com/test":true, //공개 클레임
    "name": "tester"//비공개 클레임
}

 

위와 같은 예시의 JWT가 있다.

시그니처, 서명은 해당 토큰이 조장되었거나 변경되지 않았음을 확인하는 용도로 사용하며 헤더의 인코딩 값과 내용의 인코딩 값을 합친 후 주어진 비밀키를 사용해 해시값을 생성한다.

 

JWT의 내부 정보의 헤더와 페이로드는 단순 BASE64 방식으로 인코딩하기 때문에 누구나 디코딩할 수 있다. 이에 따라 외부에서 확인해도 괜찮은 정보만 담아야 한다. 시그니처는 사용한 시크릿 키를 알아야만 디코딩이 가능하다.

장점은 서버의 부하를 줄이고 사용자 인증 정보를 안전하게 전송할 수 있다.

단점은 토큰이 탈취되면 정보가 유출될 수 있다. 한 번 발급된 토큰의 권한 변경이 어렵다.

 

만약 토큰의 유효기간이 하루라 가정한다면, 하루동안 생성한 토큰으로 많은 것을 할 수 있을 것이다. 이를 방지하기 위해 토큰의 유효기간을 짧게 설정한다면 토큰이 활성화되는 시간에만 인증과 인가를 사용할 수 있으니 불편할 것이고 아를 해결하기 위한 것이 리프레시 토큰이다.

리프레시 토큰은 액세스 토큰과 별개의 토큰으로 사용자를 인증하기 위한 용도가 아닌 액세스 토큰이 만료되었을 때 새로운 토큰을 발급하기 위해 사용하는 것으로 액세스 토큰의 유효 기간을 짧게 설정하고 리프레시 토큰의 유효 기간을 길게 설정하면 액세스 토큰이 만료되었을 때 클라이언트는 저장한 리프레시 토큰과 새로운 액세스 토큰 발급을 요청하고 서버는 리프레시 토큰이 유효한지 검사하고 DB에 리프레시 토큰을 조회해 같은지 확인하고 새로운 액세스 토큰을 생성해 응답해 주는 흐름을 가졌다.

 

더욱 자세한 정보와 라이브러리를 확인하기 위해서는 아래에 jwt 페이지에서 확인하면 좋다.

https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

 

JWT와 토큰 기반 인증에 대해서 알아보았으니 코드로 확인을 해보자.

의존성을 먼저 추가해줘야 하는 데 사용 버전에 따라 의존성을 추가해서 사용하면 된다.

dependencies {

    implementation 'io.jsonwebtoken:jjwt-api:0.9.1'
    implementation 'io.jsonwebtoken:jjwt-impl:0.9.1'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.9.1'
}
dependencies {

    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
dependencies {

    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}

 

필자는 0.11.5로 사용했다.

 

암호화에 사용할 키를 저장해야 한다. 암호화 키는 하드코딩 방식으로 내부에서 사용하는 것은 지양해야 하기 때문에  properties나 yml에 사용할 키를 저장해야 한다.

jwt:
  secret_key: [사용할 키]

 

package com.example.oauth2.token;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Date;

@Component
@Slf4j
public class JWTUtil {

    private SecretKey secretKey;

    /**
     * JWTUtil 생성자.
     * @param secret 환경 설정 파일에서 가져온 비밀 키 문자열.
     * 비밀 키를 SecretKeySpec을 통해 SecretKey 객체로 변환합니다.
     */
    public JWTUtil(@Value("${jwt.secret_key}") String secret) {
        this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName());
    }

    /**
     * JWT 토큰에서 loginId를 추출합니다.
     * @param token JWT 토큰.
     * @return 추출된 loginId.
     */
    public String getLoginId(String token) {
        Claims claim = Jwts.parserBuilder()
                .setSigningKey(secretKey) // 서명 키 설정
                .build()
                .parseClaimsJws(token) // 토큰 파싱 및 검증
                .getBody();

        return claim.get("loginId", String.class); // loginId 추출
    }

    /**
     * JWT 토큰에서 role을 추출합니다.
     * @param token JWT 토큰.
     * @return 추출된 role.
     */
    public String getRole(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(secretKey) // 서명 키 설정
                .build()
                .parseClaimsJws(token) // 토큰 파싱 및 검증
                .getBody();

        return claims.get("role", String.class); // role 추출
    }

    /**
     * JWT 토큰이 만료되었는지 확인합니다.
     * @param token JWT 토큰.
     * @return 토큰이 만료되었으면 true, 그렇지 않으면 false.
     */
    public Boolean isExpired(String token) {
        log.info("secretKey={}", secretKey);
        return Jwts.parserBuilder()
                .setSigningKey(secretKey) // 서명 키 설정
                .build()
                .parseClaimsJws(token) // 토큰 파싱 및 검증
                .getBody()
                .getExpiration()
                .before(new Date()); // 만료 시간과 현재 시간 비교
    }

    /**
     * JWT 토큰을 생성합니다.
     * @param loginId 사용자 로그인 아이디.
     * @param role 사용자 역할.
     * @param expiredMs 토큰 만료 시간 (밀리초).
     * @return 생성된 JWT 토큰.
     */
    public String createJwt(String loginId, String role, Long expiredMs) {
        return Jwts.builder()
                .claim("loginId", loginId) // loginId 클레임 추가
                .claim("role", role) // role 클레임 추가
                .setIssuedAt(new Date(System.currentTimeMillis())) // 토큰 발행 시간 설정
                .setExpiration(new Date(System.currentTimeMillis() + expiredMs)) // 토큰 만료 시간 설정
                .signWith(secretKey, SignatureAlgorithm.HS256) // 서명 알고리즘 및 키 설정
                .compact(); // 토큰 생성
    }
}

 

토큰의 생성과 각 검증을 위한 메서드들을 작성했다.

이 생성과 검증을 사용할 필터 클래스를 작성하도록 하자.

package com.example.oauth2.filter;

import com.example.oauth2.domain.Member;
import com.example.oauth2.domain.MemberRole;
import com.example.oauth2.userdetails.CustomUserDetails;
import com.example.oauth2.token.JWTUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
@Slf4j
public class JWTFilter extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //request에서 authorization 헤더를 찾는다.
        String authorization = request.getHeader("Authorization");

        //Authorization 헤더 검증
        //비어 있거나 Bearer로 시작하지 않는 경우
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            log.info("token = null");
            //토큰이 유효하지 않기 때문에 request와 response를 다음 필터로 넘긴다.
            filterChain.doFilter(request, response);

            return;
        }

        //Authorization에서 Bearer 접두사 제거
        String token = authorization.split(" ")[1];

        log.info("token={}", token);
        //token 소멸 시간 검증
        //유효기간이 만료한 경우
        if (jwtUtil.isExpired(token)) {
            log.info("token expired");
            filterChain.doFilter(request, response);

            return;
        }
        //token 검증 완료로 일시적인 세션을 생성해 세션에 user 정보 설정
        String loginId = jwtUtil.getLoginId(token);
        String role = jwtUtil.getRole(token);

        Member member = new Member();
        member.setLoginId(loginId);
        // 매번 요청마다 DB 조회해서 password 초기화 할 필요 없기에 정확한 비밀번호 넣을 필요 없음
        // 따라서 임시 비밀번호 설정
        member.setPassword("임시 비밀번호");
        member.setRole(MemberRole.valueOf(role));

        //UserDetails에 회원 정보 객체 담기
        CustomUserDetails customUserDetails = new CustomUserDetails(member);
        // 스프링 시큐리티 인증 토큰 생성
        Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());

        //세션에 사용자 등록해 일시적으로 user 세션 생성
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }
}

 

각 주석으로 어떤 기능을 수행하는지 설명했다.

 

package com.example.oauth2.filter;

import com.example.oauth2.userdetails.CustomUserDetails;
import com.example.oauth2.token.JWTUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.util.Collection;
import java.util.Iterator;

@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JWTUtil jwtUtil;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        String loginId = obtainUsername(request);
        String password = obtainPassword(request);

        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(loginId, password, null);

        return authenticationManager.authenticate(authToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                            FilterChain chain, Authentication authentication) {
        // username 추출
        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
        String username = customUserDetails.getUsername();

        // role 추출
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        String role = auth.getAuthority();

        // JWTUtil에 token 생성 요청
        String token = jwtUtil.createJwt(username, role, 60*60*1000L);

        // JWT를 response에 담아서 응답 (header 부분에)
        // key : "Authorization"
        // value : "Bearer " (인증방식) + token
        response.addHeader("Authorization", "Bearer " + token);

    }
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                              AuthenticationException failed) {

        // 실패 시 401 응답코드 보냄
        response.setStatus(401);
    }

}

 

해당 필터에서 인증과 권한 정보를 가져와서 JWT 토큰을 생성하는 필터도 이 클래스에서 진행하며 토큰을 응답 헤더에 추가해 준다.

실패 시 response에 401 응답 코드를 보내고 추가로 별도의 정보를 생성해 보낼 수 있다.

다음은 securityconfig를 jwt를 통해 인증과 인가를 하기 때문에 설정을 바꿔주자.

package com.example.oauth2.config;

import com.example.oauth2.filter.JWTFilter;
import com.example.oauth2.filter.LoginFilter;
import com.example.oauth2.token.JWTUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class TokenSecurityConfig {
    private final AuthenticationConfiguration configuration;
    private final JWTUtil jwtUtil;

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {

        return configuration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        http
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/jwt-login", "/jwt-login/", "/jwt-login/login", "/jwt-login/join").permitAll()
                        .requestMatchers("/jwt-login/admin").hasRole("ADMIN")
                        .anyRequest().authenticated());
        http
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        http
                .addFilterAt(new LoginFilter(authenticationManager(configuration), jwtUtil), UsernamePasswordAuthenticationFilter.class);

        // 로그인 필터 이전에 JWTFilter를 넣음
        http
                .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);


        return http.build();
    }
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder(){


        return new BCryptPasswordEncoder();
    }
}

 

formlogin과 http 기본 인증을 사용하지 않게끔 구성했으며 세션 관리 설정을 무상태로 설정했다.

그 후 이전에 등록한 필터를 사용하기 위해서 LoginFilter와 JWTFilter를 넣어주었다.

 

다음으로 이를 활용해 볼 컨트롤러를 구성하도록 하자.

package com.example.oauth2.controller;

import com.example.oauth2.domain.Member;
import com.example.oauth2.dto.JoinRequest;
import com.example.oauth2.dto.LoginRequest;
import com.example.oauth2.service.MemberService;
import com.example.oauth2.token.JWTUtil;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;


import java.util.Collection;
import java.util.Iterator;

@RestController
@RequiredArgsConstructor
@RequestMapping("/jwt-login")
public class JwtLoginController {

    private final MemberService memberService;
    private final JWTUtil jwtUtil;

    @GetMapping(value = {"", "/"})
    public String home(Model model) {

        model.addAttribute("loginType", "jwt-login");
        model.addAttribute("pageName", "스프링 시큐리티 JWT 로그인");

        String loginId = SecurityContextHolder.getContext().getAuthentication().getName();

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iter = authorities.iterator();
        GrantedAuthority auth = iter.next();
        String role = auth.getAuthority();

        Member loginMember = memberService.getLoginMemberByLoginId(loginId);

        if (loginMember != null) {
            model.addAttribute("name", loginMember.getName());
        }

        return "home";
    }
    @GetMapping("/join")
    public String joinPage(Model model) {

        model.addAttribute("loginType", "jwt-login");
        model.addAttribute("pageName", "스프링 시큐리티 JWT 로그인");

        // 회원가입을 위해서 model 통해서 joinRequest 전달
        model.addAttribute("joinRequest", new JoinRequest());
        return "join";
    }

    @PostMapping("/join")
    public String join(@Valid @ModelAttribute JoinRequest joinRequest,
                       BindingResult bindingResult, Model model) {

        model.addAttribute("loginType", "jwt-login");
        model.addAttribute("pageName", "스프링 시큐리티 JWT 로그인");

        // ID 중복 여부 확인
        if (memberService.checkLoginIdDuplicate(joinRequest.getLoginId())) {
            return "ID가 존재합니다.";
        }


        // 비밀번호 = 비밀번호 체크 여부 확인
        if (!joinRequest.getPassword().equals(joinRequest.getPasswordCheck())) {
            return "비밀번호가 일치하지 않습니다.";
        }

        // 에러가 존재하지 않을 시 joinRequest 통해서 회원가입 완료
        memberService.securityJoin(joinRequest);

        // 회원가입 시 홈 화면으로 이동
        return "redirect:/jwt-login";
    }

    @PostMapping("/login")
    public String login(@RequestBody LoginRequest loginRequest){

        Member member = memberService.login(loginRequest);


        if(member==null){
            return "ID 또는 비밀번호가 일치하지 않습니다!";
        }

        String token = jwtUtil.createJwt(member.getLoginId(), String.valueOf(member.getRole()), 1000 * 60 * 60L);
        return token;
    }

    @GetMapping("/info")
    public String memberInfo(Authentication auth, Model model) {

        Member loginMember = memberService.getLoginMemberByLoginId(auth.getName());

        return "ID : " + loginMember.getLoginId() + "\n이름 : " + loginMember.getName() + "\nrole : " + loginMember.getRole();
    }

    @GetMapping("/admin")
    public String adminPage(Model model) {

        return "인가 성공!";
    }
}

 

ResponseBody와 Controller를 합친 RestController를 사용한 이유는 포스트맨을 활용해 간단히 알아보기 위해서이다. 보통 JSON으로 반환 값을 두고 ResponseBody를 활용해 반환하지만 이번 예제는 반환 값으로 String을 사용하게 되면서 반환 값 자체를 문자열로 반환한다. 

join과 redirect 등이 문자열로 잘 나오게 되면 view로 구성했을 때오 뷰 리졸버에 따라 뷰도 알맞게 리턴될 것이다.

이제 포스트맨을 활용해 확인해 보자.

 

 

 

 

home과 join이 get 요청으로 잘 반환되는 것을 확인할 수 있다.

 

패스워드와 패스워드를 확인하는 패스워드체크 필드에 다른 값을 넣어 로직이 잘 작동하는지 확인도 했다.

 

실제 회원 가입 폼을 통해서 회원 가입이 정상 동작될 시 이동하는 경로를 문자열로 노출한다.

 

이후 로그인을 진행하게 되면 정상 진행 시 아래와 같이 토큰의 내용을 반환하게 만들었는데 이 토큰을 액세스 토큰으로 사용해 인증과 인가를 진행하게 된다. 이를 어떻게 사용하는지 알아보기 위해서 사용자 정보를 볼 수 있는 앤드포인트로 이동해 보자.

 

위와 같이 헤더에 AUthorization 필드에 Bearer와 발급받은 토큰을 포함해 요청하면 반환 값을 준다.

여기까지 간단한 JWT를 구성해서 사용해 보았으며 이전과 달리 테스트 코드도 없고 디버깅을 알아보지도 않았다.

이유는 위 예제에서는 설정도 간단했고 리프레시 토큰도 사용하지 않았고 이전에 설명한 흐름을 사용하지 않은 예제이다.

이 예제를 실제 흐름을 통해 진행할 수 있도록 바꾸어보도록 하겠다.

토큰 생성 - 토큰 생성 테스트 - 리프레시 토큰 생성 - 토큰 필터 구성 - 토큰 api 구성 - 테스트 순으로 진행해 보도록 하겠다.

 

package com.example.oauth2.domain;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Setter
@Getter
@Component
@ConfigurationProperties("jwt")
public class JwtProperties {
    private String secretKey;
}

 

기존 @Value로 사용하던 것이 불편해서 설정 정보에서 jwt에 해당하는 시크릿 키를 필드로 사용하기 위해 클래스를 만들었다.

package com.example.oauth2.config.jwt;

import com.example.oauth2.domain.JwtProperties;
import com.example.oauth2.domain.Member;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Collections;
import java.util.Date;
import java.util.Set;

@RequiredArgsConstructor
@Service
public class TokenProvider {

    private final JwtProperties jwtProperties;

    public String generateToken(Member member, Duration expiredAt) {
        Date now = new Date();
        return makeToken(new Date(now.getTime() + expiredAt.toMillis()), member);
    }

    private String makeToken(Date expiry, Member member) {
        Date now = new Date();

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer("test")
                .setIssuedAt(now)
                .setExpiration(expiry)
                .setSubject(member.getName())
                .claim("Id", member.getId())
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }

    public Boolean validToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(jwtProperties.getSecretKey())
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public Authentication getAuthentication(String token) {
        Claims claims = getClaims(token);
        Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("USER"));

        return new UsernamePasswordAuthenticationToken(new User(claims.getSubject(), "",authorities), token, authorities);
    }

    public Long getMemberId(String token) {
        Claims claims = getClaims(token);
        return claims.get("id", Long.class);
    }
    public Claims getClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(jwtProperties.getSecretKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

 

이전에 사용했던 코드는 모두 주석처리 하고 토큰을 생성하고 검증하는 클래스를 다시 만들었다.

이를 우선 토큰 생성과 검증이 잘 진행되는지 테스트를 진행해 보자

 

package com.example.oauth2.token;

import com.example.oauth2.domain.JwtProperties;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Builder;
import lombok.Getter;

import java.time.Duration;
import java.util.Collections;
import java.util.Date;
import java.util.Map;

@Getter
public class JwtFactory {
    private String subject = "test";
    private Date issuedAt = new Date();
    private Date expiration = new Date(new Date().getTime() + Duration.ofDays(14).toMillis());
    private Map<String, Object> claims = Collections.emptyMap();

    @Builder
    public JwtFactory(String subject, Date issuedAt, Date expiration, Map<String, Object> claims) {
        this.subject = subject != null ? subject : this.subject;
        this.issuedAt = issuedAt != null ? issuedAt : this.issuedAt;
        this.expiration = expiration != null ? expiration : this.expiration;
        this.claims = claims != null ? claims : this.claims;
    }

    public static JwtFactory withDefaultValues() {
        return JwtFactory.builder().build();
    }
    
    public String createToken(JwtProperties jwtProperties) {
        return Jwts.builder()
                .setSubject(subject)
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer("test")
                .setIssuedAt(issuedAt)
                .setExpiration(expiration)
                .addClaims(claims)
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }
}

 

테스트를 진행하기 위해 테스트 패키지 아래에 모킹용 객체를 생성했다. 

이제 테스트 코드를 작성해서 토큰 검증을 해보자.

package com.example.oauth2.token;

import com.example.oauth2.config.jwt.TokenProvider;
import com.example.oauth2.domain.JwtProperties;
import com.example.oauth2.domain.Member;
import com.example.oauth2.domain.MemberRole;
import com.example.oauth2.repository.MemberRepository;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;

import java.time.Duration;
import java.util.Date;
import java.util.Map;

import static org.assertj.core.api.Assertions.*;

@SpringBootTest
@Slf4j
public class TokenProviderTest {

    @Autowired
    TokenProvider tokenProvider;
    @Autowired
    MemberRepository memberRepository;
    @Autowired
    JwtProperties jwtProperties;

    @DisplayName("유저 정보와 만료 기간을 전달해 토큰을 만들 수 있다.")
    @Test
    void generateToken() {
        //given
        Member member = new Member("test", "1234", "tester", MemberRole.USER);
        memberRepository.save(member);

        //when
        String token = tokenProvider.generateToken(member, Duration.ofDays(14));
        log.info("token = {}", token);

        //then
        Long userId = Jwts.parserBuilder()
                .setSigningKey(jwtProperties.getSecretKey())
                .build()
                .parseClaimsJws(token)
                .getBody()
                .get("Id", Long.class);

        log.info("userId = {}", userId);

        assertThat(userId).isEqualTo(member.getId());

    }

    @DisplayName("만료된 토큰인 때에 유효성 검증에 실패한다.")
    @Test
    void validToken_invalidToken() {
        //given
        String token = JwtFactory.builder()
                .expiration(new Date(new Date().getTime() - Duration.ofDays(7).toMillis()))
                .build()
                .createToken(jwtProperties);

        //when
        boolean result = tokenProvider.validToken(token);
        log.info("token={}",token);
        //then
        assertThat(result).isFalse();
    }

    @DisplayName("토큰 기반으로 인증 정보를 가져올 수 있다.")
    @Test
    void getAuthentication() {
        //given
        String loginId = "test";
        String token = JwtFactory.builder()
                .subject(loginId)
                .build()
                .createToken(jwtProperties);

        //when
        Authentication authentication = tokenProvider.getAuthentication(token);

        //then
        assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(loginId);
    }

    @DisplayName("토큰으로 유저 ID를 가져올 수 있다.")
    @Test
    void getUserId() {
        //given
        Long userId = 1L;
        String token = JwtFactory.builder()
                .claims(Map.of("id", userId))
                .build()
                .createToken(jwtProperties);

        //when
        Long userIdByToken = tokenProvider.getMemberId(token);

        //then
        assertThat(userIdByToken).isEqualTo(userId);
    }
}

 

테스트 코드의 DisplayName으로 각 어떤 동작을 검증하는지 설명했다. 

jwt를 구성하다 보면 클레임 설정이나 복호화를 위한 키를 넣는 것이나 subject 설정 등 눈에 익어서 사용하기 수월할 것이다.

이제 테스트를 실행해서 구성한 테스트가 잘못됐지 않았는지 확인해 보자.

 

위처럼 초록 체크 표시를 확인하면 된다.

토큰을 생성하고 토큰 클레임 안의 값이나 만료된 토큰에 대한 검증 등 알아보았다.

다음으로는 토큰 사용 시 만료된 토큰에 따라 리프레시 토큰이 필요한데 이 리프레시 토큰을 구현해 보자.

 

일반적인 흐름으로는 액세스 토큰과 리프레시 토큰은 사용자 로그인 시 생성되어 리프레시 토큰은 서버에 저장되고 액세스 토큰은 Authorization 헤더에 Bearer와 함께 토큰 값이 사용된다. 이 액세스 토큰이 만료되었을 때 서버에 저장된 리프레시 토큰 유효성을 검사해 새로운 액세스 토큰을 발급하는 흐름으로 진행된다.

 

리프레시 토큰 발급을 위한 MVC 패턴 구성과 필터를 구성하자.

package com.example.oauth2.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class RefreshToken {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", updatable = false)
    private Long id;

    @Column(name = "memberId", nullable = false, unique = true)
    private Long memberId;

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

    public RefreshToken(Long memberId, String refreshToken) {
        this.memberId = memberId;
        this.refreshToken = refreshToken;
    }
    public RefreshToken update(String newRefreshToken) {
        this.refreshToken = newRefreshToken;
        return this;
    }
}

 

리프레시 토큰을 DB에 저장하기 위한 엔티티로 RefreshToken이다.

package com.example.oauth2.repository;

import com.example.oauth2.domain.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByMemberId(Long memberId);
    Optional<RefreshToken> findByRefreshToken(String refreshToken);
}

 

저장할 Repository로 springDataJPA를 사용해서 구성했다.

 

다음으로는 필터를 구성해야 한다.

필터는 실제 각종 요청을 처리하기 위한 로직으로 전달되기 전 후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공한다.

요청이 오면 헤더 값을 비교해 토큰이 있는지 확인하고 유효한 토큰이라면 시큐리티 콘텍스트 홀더 SecurityContextHolder에 인증 정보를 저장한다.

여기서 시큐리티 콘텍스트에 대해서 궁금해지는데

시큐리티 콘텍스트는 인증 객체가 저장되는 보관소이다. 인증정보가 필요할 때 언제든 인증 객체를 꺼내서 사용할 수 있는 저장소이다. 이 클래스는 스레드마다 공간을 할당하는 스레드 로컬에 저장되므로 어디서든 참조할 수 있고 다른 스레드와 공유하지 않으므로 독립적으로 사용할 수 있다. 이러한 시큐리티 컨텍스트 객체를 저장하는 객체가 시큐리티 컨텍스트 홀더이다.

알아보았으니 이제 필터를 구성해 보자.

package com.example.oauth2.filter;

import com.example.oauth2.config.jwt.TokenProvider;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    private final TokenProvider tokenProvider;
    private final static String HEADER_AUTHORIZATION = "Authorization";
    private final static String TOKEN_PREFIX = "Bearer ";


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
        String token = getAccessToken(authorizationHeader);

        if (tokenProvider.validToken(token)) {
            Authentication authentication = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String getAccessToken(String authorizationHeader) {

        if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
            return authorizationHeader.substring(TOKEN_PREFIX.length());
        }
        return null;
    }
}

 

이전에 구성한 필터와 조금 다른 구조이다.

요청 헤더의 Authorization 키의 값을 조회하고 토큰이 유효한지 확인하고 유효 할 때에 인증 정보를 설정한다.

다음으로 리프레시 토큰을 전달받아 검증하고 유효한 리프레시 토큰이라면 새로운 액세스 토큰을 생성하는 토큰 API를 구현하고 이어서 서비스와 컨트롤러를 구현해 보자.

 

package com.example.oauth2.service;

import com.example.oauth2.domain.RefreshToken;
import com.example.oauth2.repository.RefreshTokenRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class RefreshTokenService {
    private final RefreshTokenRepository refreshTokenRepository;

    public RefreshToken findByRefreshToken(String refreshToken) {
        return refreshTokenRepository.findByRefreshToken(refreshToken)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
    }
}

 

사용할 RefreshService이다. 각 계층에 맞는 설계를 위한 위임하는 역할을 한다.

package com.example.oauth2.service;

import com.example.oauth2.config.jwt.TokenProvider;
import com.example.oauth2.domain.Member;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.time.Duration;

@RequiredArgsConstructor
@Service
public class TokenService {
    private final TokenProvider tokenProvider;
    private final RefreshTokenService refreshTokenService;
    private final MemberService memberService;

    public String createNewAccessToken(String refreshToken) {
        if (!tokenProvider.validToken(refreshToken)) {
            throw new IllegalArgumentException("Unexpected token");
        }

        Long memberId = refreshTokenService.findByRefreshToken(refreshToken).getMemberId();
        Member member = memberService.findById(memberId);

        return tokenProvider.generateToken(member, Duration.ofHours(2));
    }
}

 

서비스에서 전달받는 리프레시 토큰으로 유효성을 검사하고 유효한 토큰일 때 리프레시 토큰으로 사용자 ID를 찾는다.

그 후 토큰 제공자의 generateToken() 메서드를 호출해 새로운 액세스 토큰을 생성한다.

 

컨트롤러를 구성하기 이전에 토큰 생성 요청 및 응답을 할 DTO를 만들고 컨트롤러를 구성해 보자.

package com.example.oauth2.dto;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class CreateAccessTokenRequest {
    private String refreshToken;
}

 

package com.example.oauth2.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class CreateAccessTokenResponse {
    private String accessToken;
}

 

각각 Request와 Response를 담당할 DTO를 구성했다.

package com.example.oauth2.controller;

import com.example.oauth2.dto.CreateAccessTokenRequest;
import com.example.oauth2.dto.CreateAccessTokenResponse;
import com.example.oauth2.service.TokenService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class TokenApiController {
    private final TokenService tokenService;

    @PostMapping("/api/token")
    public ResponseEntity<CreateAccessTokenResponse> createNewAccessToken(
            @RequestBody CreateAccessTokenRequest request) {
        String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken());

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(new CreateAccessTokenResponse(newAccessToken));
    }
}

 

마지막으로 Post 요청에 따라 토큰 서비스에서 리프레시 토큰을 기반으로 새로운 액세스 토큰을 만들어준다.

 

이를 확인하기 위해 테스트 코드를 만들어보고 테스트 코드에 브레이크 포인트를 두어서 디버깅으로 확인해 보고 포스팅을 마치도록 하겠다.

package com.example.oauth2.controller;



import com.example.oauth2.domain.JwtProperties;
import com.example.oauth2.domain.Member;
import com.example.oauth2.domain.MemberRole;
import com.example.oauth2.domain.RefreshToken;
import com.example.oauth2.dto.CreateAccessTokenRequest;
import com.example.oauth2.repository.MemberRepository;
import com.example.oauth2.repository.RefreshTokenRepository;
import com.example.oauth2.token.JwtFactory;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.util.Map;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class TokenApiControllerTest {
    @Autowired
    protected MockMvc mockMvc;
    @Autowired
    protected ObjectMapper objectMapper;
    @Autowired
    private WebApplicationContext context;
    @Autowired
    JwtProperties jwtProperties;
    @Autowired
    MemberRepository memberRepository;
    @Autowired
    RefreshTokenRepository refreshTokenRepository;

    @BeforeEach
    public void mockMvcSetUp() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
        memberRepository.deleteAll();
    }

    @DisplayName("새로운 액세스 토큰 발급")
    @Test
    void createNewAccessToken() throws Exception {
        //given
        final String url = "/api/token";

        Member member = new Member("test", "1234", "tester", MemberRole.USER);
        memberRepository.save(member);

        String refreshToken = JwtFactory.builder()
                .claims(Map.of("id", member.getId()))
                .build()
                .createToken(jwtProperties);

        refreshTokenRepository.save(new RefreshToken(member.getId(), refreshToken));

        CreateAccessTokenRequest request = new CreateAccessTokenRequest();
        request.setRefreshToken(refreshToken);

        final String requestBody = objectMapper.writeValueAsString(request);

        //when
        ResultActions resultActions = mockMvc.perform(post(url)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(requestBody));

        //then
        resultActions
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accessToken").isNotEmpty());
    }

}

 

 

테스트 유저를 생성해 리프레시 토큰을 만들어 데이터 베이스에 저장하고 토큰 생성 api의 요청 본문에 리프레시 토큰을 포함해 요청 객체를 생성한다. 토큰 추가 api에 요청을 json 타입으로 보내고 객체와 함께 본문으로 보낸다 응답 코드가 201인 created인지 확인하고 응답으로 온 액세스 토큰이 비어 있지 않은지 확인한다.

이로써 테스트까지 해보았고 디버그를 통해 안에 값들을 확인해 보자.

 

리프레시 토큰이 생성되고 생성된 리프레시 토큰이 저장되는 것을 확인할 수 있고

 

request에 리프레시 토큰이 들어가는 것을 확인할 수 있다.

 

resquestBody에 json 형식으로 리프레시 토큰이 들어가고 http 요청에 대한 응답으로 201 status로 응답이 온 것을 확인할 수 있다.

여기까지 JWT에 대해와 액세스와 리프레시 토큰을 발급하고 사용하는 것과 흐름을 알아보았다.

실제 액세스 토큰을 생성해서 사용하는 것은 위 예제로 알아보았고 리프레시 토큰까지 적용해서 사용하려 한다면 로그인 로직에 액세스 토큰을 생성해서 발급할 때 리프레시 토큰까지 생성하고  리프레시 토큰은 DB에 저장 후 헤더에 액세스 토큰 값을 넣어 반환하면 된다.

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
    // 사용자 인증 로직
    Member member = authenticate(loginRequest.getLoginId(), loginRequest.getPassword());

    // 토큰 생성
    String accessToken = tokenProvider.generateAccessToken(member, Duration.ofMinutes(15));
    String refreshToken = tokenProvider.generateRefreshToken(member, Duration.ofDays(7));

    // 리프레시 토큰을 DB에 저장
    refreshTokenRepository.save(new RefreshToken(member.getId(), refreshToken));

    return ResponseEntity.ok()
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
            .body(new LoginResponse(accessToken, refreshToken));
}

 

토큰 유효성을 검사하고 리프레시 토큰을 통해 새로운 액세스 토큰을 발급하는 로직에 대해서는 SecurityConfig에 두는 것이 가장 간결하고 간편하게 관리할 수 있다. 필터 클래스를 만들어 유효성을 검증하고 액세스 토큰을 새로 만들어 반환해 주는 로직이 필요하며 SecurityConfig에

.addFilter(new JWTAuthenticationFilter(authenticationManager(), jwtUtil))
.addFilter(new JWTAuthorizationFilter(authenticationManager(), jwtUtil, customUserDetailsService))

해당 방식으로 두고 인증과 검증을 필터를 통해 form 로그인을 통해 진행할 수 있다.

 

이렇게 지금까지 JWT에 대해서 알아보았다. 이전에도 한 번 사용해 보았지만 깊게 이해하고 사용하지는 않았어서 이번 기회에 더욱 깊은 이해를 하고 사용할 수 있었다.

다음 포스팅은 소셜 로그인에 사용되는 Oauth2 방식을 알아보고 구글 로그인을 구현해 보도록 하겠다.

 

'spring' 카테고리의 다른 글

Springboot Oauth2 KaKaoLogin, NaverLogin  (0) 2024.07.17
Spring Oauth2 구글 로그인  (2) 2024.07.16
Spring Security에 대해  (0) 2024.07.13
Springboot Session에 대해  (0) 2024.07.12
Springboot Cookie에 대해  (1) 2024.07.11