Java & Spring

[Spring Boot 2] Spring Security - JWT 토큰을 이용한 인증 구현

ju_young 2024. 3. 5. 21:49
728x90

Dependency

dependencies {  
    implementation 'org.springframework.boot:spring-boot-starter-security'  
    implementation 'org.springframework.boot:spring-boot-starter-web'  
    compileOnly 'org.projectlombok:lombok'  
    annotationProcessor 'org.projectlombok:lombok'  
    testImplementation 'org.springframework.boot:spring-boot-starter-test'  
    testImplementation 'org.springframework.security:spring-security-test'  

    //JWT
    //https://github.com/jwtk/jjwt#install  
    implementation 'io.jsonwebtoken:jjwt-api:0.12.5'  
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'  
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'  
}

jsonwebtoken은 지속적으로 업데이트가 되며 메소드명이나 사용 방법이 변경될 수 있다. 따라서 https://github.com/jwtk/jjwt#install 의 공식 문서와 버전을 반드시 확인해야한다.


UserDetails 정의

우선 SecurityContextHoler에 저장될 UserDetails 를 정의한다. 간단하게 username(email), password, nickname을 포함하도록 한다.

public record MemberPrincipal(  
        Long id, String username, String password, String nickname, Collection<? extends GrantedAuthority> authorities)  
        implements UserDetails {  

    public static MemberPrincipal of(Long id, String username, String password, String nickname) {  
        return new MemberPrincipal(id, username, password, nickname, Set.of(new SimpleGrantedAuthority("USER")));  
    }  

    @Override  
    public Collection<? extends GrantedAuthority> getAuthorities() {  
        return authorities;  
    }  

    @Override  
    public String getPassword() {  
        return password;  
    }  

    @Override  
    public String getUsername() {  
        return username;  
    }  

    @Override  
    public boolean isAccountNonExpired() {  
        return true;  
    }  

    @Override  
    public boolean isAccountNonLocked() {  
        return true;  
    }  

    @Override  
    public boolean isCredentialsNonExpired() {  
        return true;  
    }  

    @Override  
    public boolean isEnabled() {  
        return true;  
    }  
}

JWT (Json Web Token)

JWT 토큰은 보통 Authorization HTTP 헤더에 Bearer <토큰>과 같이 지정하여 사용한다. 구조는 <헤더>.<페이로드>.<서명>으로 이루어진다.

  • 헤더: key를 식별하는 값, 토큰 type, 암호화 알고리즘의 정보가 담겨있다.
  • 페이로드: key와 value 형태로 된 인증/인가 정보들을 담는다. 이를 Claim이라고 한다.
  • 서명: 헤더, 페이로드와 서버의 private key를 합친 값을 암호화한 것이다. 따라서 서버의 private key가 외부에 유출되지 않는 이상 복호화할 수 없고 이로 인해 토큰의 위변조 여부를 확인할 수 있다.

JWT 속성 설정

JWT 발행을 위해 사용할 속성 값을 application.yml에 설정한다.

jwt:  
  token:  
    secret-key: "aaaaagaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # >= 256bits  
    access-expired-ms: 31540000000 # 24 * 3600000 = 24 hours

Properties 클래스 정의

application.yml에 설정한 속성 값을 사용하기 위해 Properties 클래스를 정의한다. 이때 Application 클래스에 @ConfigurationPropertiesScan를 추가해주어야한다.

/**  
 * @param cookieName 쿠키에 저장될때 사용되는 키 값  
 * @param secretKey 토큰을 생성할때 사용되는 키 값  
 * @param accessExpiredMs access 토큰 만료 시간 (ms)  
 *  @param refreshExpiredMs refresh 토큰 만료 시간 (ms)  
 */
@ConfigurationProperties(prefix = "jwt.token")  
public record JwtProperties(String cookieName, String secretKey, long accessExpiredMs, long refreshExpiredMs) {}

JWT 토큰 생성

토큰에 email, nickname, id, role(권한) 정보를 담는다고 할 떄 아래와 같이 코드를 작성할 수 있다.

private static final String KEY_EMAIL = "email";  
private static final String KEY_NICKNAME = "nickname";  
private static final String KEY_SPEC = "spec";

public String generateAccessToken(String email, String nickname, String spec) {  
    //email, nickname, id, role 정보를 담는다.
    Map<String, String> claims = new HashMap<>();  
    claims.put(KEY_EMAIL, email);  
    claims.put(KEY_NICKNAME, nickname);  
    claims.put(KEY_SPEC, spec); // "id:role"  

    return Jwts.builder()  
            .claims(claims) //claims 지정
            .issuedAt(new Date(System.currentTimeMillis())) //토큰 발행 시간 설정
            .expiration(new Date(System.currentTimeMillis() + jwtProperties.accessExpiredMs()))  //토큰 만료 시간 설정
            .signWith(getKey(jwtProperties.secretKey())) //서명을 위한 key 설정 
            .compact();
}

private SecretKey getKey(String key) {  
    return Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8));  
}

여기서 Keys.hmacShaKeyFor는 내부에서 key의 길이에 따라 자동으로 암호화 알고리즘을 지정한다.

if (bitLength >= 512) {  
    return new SecretKeySpec(bytes, "HmacSHA512");  
} else if (bitLength >= 384) {  
    return new SecretKeySpec(bytes, "HmacSHA384");  
} else if (bitLength >= 256) {  
    return new SecretKeySpec(bytes, "HmacSHA256");  
}

Payload 파싱

/**
* JWT 토큰에서 인증/인가 정보를 파싱한다. 이때 토큰의 유효성 검증도 내부에서 진행하기때문에 유효성 검사* 를 위해서 사용할 수도 있다.
*/
public Claims parseOrValidateClaims(String token) {  
    try {  
        return Jwts.parser()  
                //위변조 여부 확인
                .verifyWith(getKey(jwtProperties.secretKey()))  
                .build()  
                //Claim 파싱
                .parseSignedClaims(token)  
                .getPayload();  
    } 
    //토큰이 만료된 경우 예외 처리
    catch (ExpiredJwtException e) {  
        //AuthException은 따로 정의한 Exception 클래스이다.
        throw new AuthException(ErrorCode.EXPIRED_TOKEN, e);  
    }  
}

Claim

Claim에 있는 모든 인증/인가 정보를 배열로 파싱하는 메소드를 작성한다.

/**  
 * @return {id, email, nickname, authority}  
 */
 public String[] parseSpecification(Claims claims) {  
    try {  
        String[] spec = claims.get(TokenProvider.KEY_SPEC, String.class).split(":");  
        return new String[] {  
            spec[0], // id  
            claims.get(TokenProvider.KEY_EMAIL, String.class),  
            claims.get(TokenProvider.KEY_NICKNAME, String.class),  
            spec[1] // authority  
        };  
    } catch (RequiredTypeException e) {  
        return null;  
    }  
}

또 다른 메소드로 Claim에 있는 정보들로 UserDetails를 생성하여 반환하는 메소드를 작성한다.

/**  
 * access token의 형식이 잘못되었을 경우 ANONYMOUS로 반환한다.
 */  
public MemberPrincipal getUserDetails(Claims claims) {  
    String[] parsed = Optional.ofNullable(claims)  
            .map(this::parseSpecification)  
            .orElse(new String[] {null, ANONYMOUS, ANONYMOUS, ANONYMOUS});  

    return MemberPrincipal.of(  
            Long.parseLong(parsed[0]), parsed[1], parsed[2], parsed[3]); // id, email, nickname, authority(role)
}

전체 코드는 Github에서 확인 할 수 있다.


비지니스 로직

로그인을 처리하는 비지니스 로직을 구현한다. id, nickname, role은 대충 임의로 지정했다.

@Service  
@RequiredArgsConstructor  
public class SignInService {  
    private final TokenProvider tokenProvider;  

    public String signIn(String email, String password) {  
        // 비밀번호 일치 확인  
        if (!password.equals("admin")) {  
            throw new AuthException(ErrorCode.INVALID_PASSWORD);  
        }  
        // AccessToken 발급  
        return tokenProvider.generateAccessToken(email, "admin", String.format("%s:%s", 1, "ADMIN"));  
    }  
}

여기서 TokenProvider는 위에서 JWT를 발행하고 파싱하는 것을 수행하는 클래스이다.


API 추가

프론트에서 로그인을 요청하면 서버에서 받을 수 있도록 API를 추가한다. 엔드포인트는 /api/v1/signin으로 지정하며 발행된 토큰을 반환하도록 한다.

@RestController  
@RequestMapping("/api/v1/signin")  
@RequiredArgsConstructor  
public class SignInApi {  
    private final SignInService signInService;  

    @PostMapping  
    public ResponseEntity<String> signIn(@RequestBody LoginRequest loginRequest) {  
        //토큰 발행
        String accessToken = signInService.signIn(loginRequest.email(), loginRequest.password());  
        //발행한 토큰을 반환
        return ResponseEntity.ok(accessToken);  
    }  
}

Custom Login Page

JWT 토큰을 이용하여 인증하기위해서는 기본으로 제공되는 Login Form보다 따로 Customize한 로그인 페이지를 사용하는 것이 좋다. (어차피 이후에 OAuth 기능을 추가하려면 따로 Customize해야한다.)

application.yml 설정

기본적으로 static 디렉토리에 있는 html을 사용하기 때문에 spring.mvc.view.suffix.html으로 설정하여 Controller 반환시 확장자까지 적어야하는 번거로움을 줄이도록 한다.

spring:  
  mvc:  
    view:  
      suffix: .html

Contoller

@Slf4j  
@Controller  
public class SignInController {  

    @GetMapping("/signin")  
    public String signIn() {  
        return "login";  
    }  
}

html

로그인 html 파일(login.html)을 static에 추가한다.


Security Configuration

이제 SecurityFilterChainUserDetailsService를 추가해준다.

@Configuration  
public class SecurityConfig {  

    @Bean  
    public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtTokenFilter jwtTokenFilter) throws Exception {  
        return http
                //csrf 비활성
                .csrf().disable()  
                .authorizeHttpRequests(auth -> auth.requestMatchers(  
                                PathRequest.toStaticResources().atCommonLocations())  
                        .permitAll()  
                        //로그인 요청의 엔드포인트에 대한 모든 권한 허용
                        .mvcMatchers("api/v1/signin")  
                        .permitAll()
                        //이외의 모든 요청은 인증이 필요
                        .anyRequest()  
                        .authenticated())  
                //session 정책을 STATELESS로 설정
                //JWT 토큰은 서버에 저장하지 않을 목적으로 사용되기 때문에 Session을 생성할 필요가 없다.
                .sessionManagement()  
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)  
                .and()  
                //Custom 로그인 페이지의 엔드포인트 설정과 모든 권한 허용
                .formLogin(form -> form.loginPage("/signin").permitAll())  
                //JWT 토큰을 사용한 실질적인 인증을 수행하는 필터, JwtTokenFilter 추가
                .addFilterAfter(jwtTokenFilter, SessionManagementFilter.class)  
                .build();  
    }  

    //UserDetailsService 추가
    @Bean  
    public UserDetailsService userDetailsService() {  
        return username -> MemberPrincipal.of(1L, "admin@gmail.com", "{noop}admin", "admin");  
    }  
}

 

여기서 Controller를 추가하지 않고 임의로 `.formLogin(form -> form.loginPage("/login.html").permitAll())` 로 설정해주어도 된다.


JwtTokenFilter

마지막으로 실질적인 인증을 수행하는 JwtTokenFilter를 작성한다.

@Component  
@RequiredArgsConstructor  
public class JwtTokenFilter extends OncePerRequestFilter {  

    public static final String ACCESS_TOKEN_HEADER = HttpHeaders.AUTHORIZATION;  
    public static final String TOKEN_PREFIX = "Bearer";  
    private final TokenProvider tokenProvider;  

    @Override  
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)  
            throws ServletException, IOException {  
        String accessToken = parseBearerToken(request);  
        try {  
            //토큰이 없을 경우 다음 필터 수행
            if (!StringUtils.hasText(accessToken)) {  
                filterChain.doFilter(request, response);  
                return;  
            }  
            //토큰의 정보를 통해 인증 수행
            setAuthentication(request, tokenProvider.parseOrValidateClaims(accessToken), accessToken);  
        } catch (Exception e) {  
            log.error("Error occurs during authenticate, {}", e.getMessage());  
        }  
        //인증 수행 후 다음 필터 수행
        filterChain.doFilter(request, response);  
    }  

    /**  
     * reqeust header의 토큰 파싱  
     */  
    private String parseBearerToken(HttpServletRequest request) {  
        return Optional.ofNullable(request.getHeader(ACCESS_TOKEN_HEADER)) 
                //"Bearer"로 시작하고 빈 문자열이 아닌 것을 확인 
                .filter(token -> !ObjectUtils.isEmpty(token) && token.startsWith(TOKEN_PREFIX))  
                //접두사에 붙은 "Bearer" 삭제 
                .map(token -> token.substring(TOKEN_PREFIX.length()).trim())  
                .orElse(null);  
    }  

    private void setAuthentication(HttpServletRequest request, Claims claims, String accessToken) {  
        //토큰의 Claim 정보로 UserDetails(MemberPrincipal)을 생성
        MemberPrincipal memberPrincipal = tokenProvider.getUserDetails(claims);  
        //토큰과 생성한 UserDetails를 사용하여 Authentication(UsernamePasswordAuthenticationToken) 생성
        UsernamePasswordAuthenticationToken authenticated = UsernamePasswordAuthenticationToken.authenticated(  
                memberPrincipal, accessToken, memberPrincipal.getAuthorities());  
        authenticated.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));  
        //SecurityContextHolder에 저장
        SecurityContextHolder.getContext().setAuthentication(authenticated);  
    }  
}

Appendix

인증 정보를 Session에 저장할 경우 서버의 자원을 소비하게된다. 사용자가 100만명이 있고 Session 하나 당 1KB를 차지한다고 쳐도 1GB이다. 따라서 서버가 아닌 클라이언트에 인증 정보에 저장할 수 있도록 하여 서버의 부담을 줄여줄 수 있도록 JWT 토큰을 사용하는 것이다. 하지만 서비스의 크기가 크지않을 경우 그냥 Session에 저장해도 괜찮을 것 같다.

 

JWT 토큰이 클라이언트에 저장되지만 탈취, 변조 등의 위험이 있다. 마치 신분증을 개개인이 가지고 있기때문에 그에 대한 책임도 서버가 지지않고 개인이 지게되는 것이다.

 

클라이언트에 토큰을 저장하는 방법은 크게 Cookie와 LocalStorage 두 가지로 볼 수 있다. Cookie는 Http 요청마다 헤더에 추가되지만 LocalStorage에 저장할 경우 직접 헤더에 추가해야한다는 차이가 있다. 따라서 Cookie는 HttpOnly, Secure 등을 설정하여 보안을 보완하기도 한다.

Redis

또 다른 방법으로는 In-memory DB인 Redis를 사용하는 것이다. Redis에 유저 정보를 저장하고 꺼내쓰게되면 RDB에서 조회하는 것보다 빠르게 수행될 수 있다.

 

Redis를 사용했을 때의 과정을 정리해보자.

  1. 최초 로그인시 redis에 유저 정보 캐싱하고 클라이언트에 토큰 전달
  2. 페이지 전환시 클라이언트에 저장한 토큰을 서버에 전달
  3. 전달받은 토큰으로 redis에서 유저 정보를 꺼냄
  4. 유저 정보를 SecurityContextHoler에 저장 (=인증 수행)

여기서 한 가지 의문이 생길 수 있다. 그냥 SESSIONID를 redis의 key로 사용하면 안되는걸까?

Session vs JWT

기본적으로 Spring Security는 Session에 Authentication을 저장하며 로그인(인증) 후 Session Fixation Attack을 예방하기 위해 Session Id를 변경시키는 전략을 적용한다. 즉, 로그인할 때마다 Session Id를 바꾼다는 말이다. 하지만 또 다른 관점에서는 한 번 로그인하면 바뀐 Session Id는 고정되어있다. 그리고 이 Session Id는 클라이언트에 쿠키 값으로 저장되어 사용된다.

Session Id를 사용하면 무조건 DB나 세션 Repository에서 유저 정보를 가져와야한다. 반면에 JWT 토큰은 토큰 내에 인증/인가를 위한 정보가 담겨있기 때문에 다른 곳에서 따로 유저 정보를 가져올 필요가 없어진다.

또 한가지 JWT 토큰은 refreshToken을 추가할 수 있다. Session Id 또는 JWT 토큰은 만료가 되면 다시 로그인해야하는데 refreshToken을 추가하면 JWT 토큰이 만료되어도 서버 내부에서 재발행해주는 것이다. 이로 인해 한 번 로그인하면 만료될 때까지 사용하는 것이 아닌 주기적으로 토큰이 재발행되면서 탈취, 변조의 위험을 감소시킬 수 있다.

 

 

[reference]
https://www.daleseo.com/jwt/

728x90