반응형

의존성 추가

implementation("org.springframework.boot:spring-boot-starter-security")
implementation("io.jsonwebtoken:jjwt:0.9.1")
implementation("javax.xml.bind:jaxb-api:2.3.1")

SecurityConfig

@EnableWebSecurity
@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(
        HttpSecurity http,
        UserDetailsService userDetailsService,
        JwtTokenService jwtTokenService
    ) throws Exception {
        return http
            .authorizeRequests(authorize -> {
                authorize.requestMatchers("/api/v**/authentication").permitAll();
                authorize.anyRequest().authenticated();
            })
            .sessionManagement(session -> {
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
            })
            .httpBasic(Customizer.withDefaults())
            .csrf(csrf -> {
                csrf.disable();
            })
            .addFilterBefore(new JwtAuthenticationFilter(userDetailsService, jwtTokenService), UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
        User.UserBuilder users = User.builder();
        UserDetails user = users
            .username("tyler")
            .password(passwordEncoder.encode("1234"))
            .roles("USER")
            .build();
        return new InMemoryUserDetailsManager(user);
    }
}

JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final UserDetailsService userDetailsService;
    private final JwtTokenService jwtTokenService;

    private static final String HEADER_AUTHORIZATION = "Authorization";
    private static final String TOKEN_PREFIX = "Bearer ";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        UserToken userToken = Optional.ofNullable(request.getHeader(HEADER_AUTHORIZATION))
            .filter(it -> it.startsWith(TOKEN_PREFIX))
            .map(it -> it.substring(TOKEN_PREFIX.length()))
            .map(jwtTokenService::parse)
            .filter(it -> !it.isExpired())
            .filter(it -> it.getUsername() != null)
            .orElse(null);

        if (userToken != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(userToken.getUsername());

            if (userDetails != null) {
                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}

JwtService

@RequiredArgsConstructor
@Service
public class JwtTokenService {
    private static final String SIGNING_KEY = "test-signing-key";

    public String createToken(UserToken userToken) {
        return Jwts.builder()
            .setSubject("user")
            .claim("username", userToken.getUsername())
            .setExpiration(userToken.getExpiration())
            .signWith(SignatureAlgorithm.HS512, SIGNING_KEY.getBytes())
            .compact();
    }

    public UserToken parse(String token) {
        Claims claims = Jwts.parser()
            .setSigningKey(SIGNING_KEY.getBytes())
            .parseClaimsJws(token)
            .getBody();

        return UserToken.builder()
            .username(claims.get("username", String.class))
            .expiration(claims.getExpiration())
            .build();
    }
}

UserToken

@Getter
@Builder
public class UserToken {
    private final String username;
    private final Date expiration;

    public boolean isExpired() {
        return new Date().getTime() > expiration.getTime();
    }
}

AuthenticationDto

public class AuthenticationDto {
    @Getter
    @RequiredArgsConstructor
    public static class Req {
        private final String username;
        private final String password;
    }

    @Getter
    @Builder
    public static class Res {
        private final String token;
    }
}

AuthenticationController

@RequiredArgsConstructor
@RestController
public class AuthenticationController {
    private final UserDetailsService userDetailsService;
    private final JwtTokenService jwtTokenService;
    private final PasswordEncoder passwordEncoder;

    private final long EXPIRATION_TIME = 864_000_000; // 10일 (단위: 밀리초)

    @PostMapping("/api/v1/authentication")
    public AuthenticationDto.Res authentication(@RequestBody AuthenticationDto.Req request) {
        String token = Optional.ofNullable(userDetailsService.loadUserByUsername(request.getUsername()))
            .filter(it -> passwordEncoder.matches(request.getPassword(), it.getPassword()))
            .map(it -> jwtTokenService.createToken(UserToken.builder()
                .username(request.getUsername())
                .expiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .build()))
            .orElseThrow(() -> new IllegalStateException("user not found. username: " + request.getUsername()));

        return AuthenticationDto.Res.builder()
            .token(token)
            .build();
    }
}

토큰 발급

요청

curl -d '{"username":"tyler", "password":"1234"}' -H 'Content-Type: application/json' -X POST http://localhost:8080/api/v1/authentication

응답

{"token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwidXNlcm5hbWUiOiJ0eWxlciIsImV4cCI6MTY5Njg2NjQ3Mn0.Bpfx9yfQvS7B_fGzv2VlfuIW4tT72n7be97olVJe_L7-WgpCAbtBi3CrNZIffboCaGR5zLr-uHDSYRmyp1A45Q"}

요청 테스트

위에서 응답으로 받은 토큰 값을 Authorization 헤더로 세팅

curl -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwidXNlcm5hbWUiOiJ0eWxlciIsImV4cCI6MTY5Njg2NjQ3Mn0.Bpfx9yfQvS7B_fGzv2VlfuIW4tT72n7be97olVJe_L7-WgpCAbtBi3CrNZIffboCaGR5zLr-uHDSYRmyp1A45Q' http://localhost:8080/api/v1/demo
반응형

+ Recent posts