이제 세션 기반 로그인이 아닌 JWT를 이용한 로그인을 코드로 적용해보자
1. 먼저 jwt를 위해 필요한 라이브러리를 추가한다.
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.18.1</version>
</dependency>
2. 다음으로 jwt 토큰을 구성하기 위한 설정 값들을 설정한다.
jwt:
header: "${헤더이름}"
issuer: "${발급자}"
client-secret: "${서명을 위한 키}"
expiry-seconds: "${토큰 만료 시간}"
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtConfigure {
private String header;
private String issuer;
private String clientSecret;
private int expirySeconds;
public String getHeader() {
return header;
}
public void setHeader(String header) {
this.header = header;
}
public String getIssuer() {
return issuer;
}
public void setIssuer(String issuer) {
this.issuer = issuer;
}
public String getClientSecret() {
return clientSecret;
}
public void setClientSecret(String clientSecret) {
this.clientSecret = clientSecret;
}
public int getExpirySeconds() {
return expirySeconds;
}
public void setExpirySeconds(int expirySeconds) {
this.expirySeconds = expirySeconds;
}
}
3. 토큰을 발급하기 위해 Jwt 클래스를 작성한다. 해당 클래스는 토큰 발급, 토큰 유효 검사 메소드를 수행한다.
public class Jwt {
private final String issuer; // 발행자
private final String clientSecret; // 클라이언트 비밀 키
private final int expirySeconds; // 만료 시간 (초)
private final Algorithm algorithm; // 서명시 적용할 암호화 알고리즘(앞서 작성한 clientSecret과 함게 사용
private final JWTVerifier jwtVerifier; // JWT 검증 객체
// 생성자
public Jwt(String issuer, String clientSecret, int expirySeconds) {
this.issuer = issuer;
this.clientSecret = clientSecret;
this.expirySeconds = expirySeconds;
this.algorithm = Algorithm.HMAC512(clientSecret); // HMAC512 알고리즘 사용
this.jwtVerifier = JWT.require(algorithm)
.withIssuer(issuer)
.build(); // 검증 객체 초기화
}
// JWT 토큰 생성 메소드
public String sign(Claims claims) {
Date now = new Date();
JWTCreator.Builder builder = JWT.create();
builder.withIssuer(issuer); // 발행자 설정
builder.withIssuedAt(now); // 발행 시간 설정
if (expirySeconds > 0) {
builder.withExpiresAt(new Date(now.getTime() + (expirySeconds * 1_000L))); // 만료 시간 설정
}
builder.withClaim("username", claims.username); // 사용자 이름 설정
builder.withArrayClaim("roles", claims.roles); // 역할 설정
return builder.sign(algorithm); // 토큰 서명 및 반환
}
// JWT 토큰 검증 메소드
public Claims verify(String token) throws JWTVerificationException {
return new Claims(jwtVerifier.verify(token)); // 토큰 검증 및 클레임 반환
}
// 클레임 클래스
public static class Claims {
String username; // 사용자 이름
String[] roles; // 역할
Date iat; // 발행 시간
Date exp; // 만료 시간
private Claims() {}
Claims(DecodedJWT decodedJWT) {
Claim username = decodedJWT.getClaim("username");
if (!username.isNull()) {
this.username = username.asString(); // 사용자 이름 추출
}
Claim roles = decodedJWT.getClaim("roles");
if(!roles.isNull()) {
this.roles = roles.asArray(String.class); // 역할 추출
}
this.iat = decodedJWT.getIssuedAt(); // 발행 시간 추출
this.exp = decodedJWT.getExpiresAt(); // 만료 시간 추출
}
// 클레임 생성 메소드
public static Claims from(String username, String[] roles) {
Claims claims = new Claims();
claims.username = username;
claims.roles = roles;
return claims;
}
// 클레임을 맵으로 변환하는 메소드
public Map<String, Object> asMap() {
Map<String, Object> map = new HashMap<>();
map.put("username", username);
map.put("roles", roles);
map.put("iat", iat());
map.put("exp", exp());
return map;
}
// 발행 시간 반환 메소드
public long iat() {
return iat != null ? iat.getTime() : -1;
}
// 만료 시간 반환 메소드
public long exp() {
return exp != null ? exp.getTime() : -1;
}
}
}
4. JWT 인증객체 생성
public class JwtAuthentication {//SecurityContextHolder에 담을 인증객체
public final String token;
public final String username;
public JwtAuthentication(String token, String username) {
checkArgument(isNotEmpty(token), "token must be provided");
checkArgument(isNotEmpty(username), "username must be provided");
this.token = token;
this.username = username;
}
}
5. jwt 토큰 인증 요청시 거칠 필터 작성
public class JwtAuthenticationFilter extends GenericFilterBean {
private final Logger logger = LoggerFactory.getLogger(this.getClass().getName());
private final String headerKey; // JWT 토큰을 찾을 HTTP 헤더의 키
private final Jwt jwt; // JWT 처리 객체
public JwtAuthenticationFilter(String headerKey, Jwt jwt) {
this.headerKey = headerKey;
this.jwt = jwt;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
// HTTP 요청에서 JWT 토큰을 찾고, 토큰을 검증하여 인증 정보를 생성하고 SecurityContextHolder에 저장
var request = (HttpServletRequest) servletRequest;
var response = (HttpServletResponse) servletResponse;
if (SecurityContextHolder.getContext().getAuthentication() == null) {
try {
var token = getToken(request);
Optional.ofNullable(token)
.map(this::verify)
.filter(claims -> isNotEmpty(claims.username) && !getAuthorities(claims).isEmpty())
.ifPresent(claims -> {
logger.debug("Jwt parsed: {}", claims);
var username = claims.username;
var authorities = getAuthorities(claims);
var authentication = new JwtAuthenticationToken(new JwtAuthentication(token, username),
null,
authorities
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication); // 인증 정보 저장
});
} catch (Exception e) {
logger.error("Failed to verify token", e);
return;
}
} else {
logger.debug("SecurityContextHolder not populated with security token, as it already contained: '{}'",
SecurityContextHolder.getContext().getAuthentication());
}
filterChain.doFilter(request, response); // 필터 체인 계속
}
private String getToken(HttpServletRequest request) {
// HTTP 헤더에서 JWT 토큰을 추출
String token = request.getHeader(headerKey);
if (isNotEmpty(token)) {
logger.debug("Found token in header: {}", token);
try {
return URLDecoder.decode(token, "UTF-8"); // 토큰 디코딩
} catch (Exception e) {
logger.error("Failed to decode token: {}", token, e);
}
}
return null;
}
private Jwt.Claims verify(String token) {
return jwt.verify(token); // 토큰 검증
}
private List<? extends GrantedAuthority> getAuthorities(Jwt.Claims claims) {
// 클레임에서 권한 정보 추출
return Optional.ofNullable(claims.roles)
.filter(roles -> roles.length > 0)
.map(roles -> Arrays.stream(roles).map(SimpleGrantedAuthority::new).toList())
.orElse(Collections.emptyList());
}
}
6. 유효한 사용자인지 검증하긴 위한 UserService 작성
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public User login(String username, String credentials) {
var user = userRepository.findByLoginIdWithAuthorities(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found user for login id: " + username));
user.checkPassword(passwordEncoder, credentials);
return user;
}
public User findByLoginId(String loginId) {
return userRepository.findByLoginIdWithAuthorities(loginId)
.orElseThrow(() -> new UsernameNotFoundException("User not found user for login id: " + loginId));
}
/*@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByLoginId(username)
.map(user -> User.builder()
.username(user.getLoginId())
.password(user.getPassword())
.authorities(user.getGroup().getAuthorities())
.build())
.orElseThrow(() -> new UsernameNotFoundException("User not found user for login id: " + username));
}*/
}
-> 이때 User를 조회할 때 fetch 조인을 써서 n+1 문제, LazyInitailiztion 예외 방지
7. 인증을 직접적으로 처리해주는 AuthenticationProvider클래스 생성
public class JwtAuthenticationProvider implements AuthenticationProvider {
private final Jwt jwt; // JWT 처리 객체
private final UserService userService; // 사용자 서비스
public JwtAuthenticationProvider(Jwt jwt, UserService userService) {
this.jwt = jwt;
this.userService = userService;
}
@Override
public boolean supports(Class<?> authentication) {
// JwtAuthenticationToken 클래스 또는 하위 클래스를 지원하는지 확인
return (JwtAuthenticationToken.class.isAssignableFrom(authentication));
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
JwtAuthenticationToken token = (JwtAuthenticationToken) authentication;
// 사용자 인증 처리
return processUserAuthentication(String.valueOf(token.getPrincipal()), String.valueOf(token.getCredentials()));
}
private Authentication processUserAuthentication(String principal, String credentials) {
try {
var user = userService.login(principal, credentials); // 사용자 로그인
var authorities = user.getGroup().getAuthorities(); // 권한 정보 가져오기
var token = getToken(user.getLoginId(), authorities); // 토큰 생성
var authenticated = new JwtAuthenticationToken(new JwtAuthentication(token, user.getLoginId()),
null,
authorities
);
authenticated.setDetails(user); // 사용자 정보 설정
return authenticated; // 인증된 토큰 반환
} catch (IllegalArgumentException e) {
throw new BadCredentialsException(e.getMessage()); // 잘못된 자격 증명 예외 처리
} catch (Exception e) {
throw new AuthenticationServiceException(e.getMessage()); // 인증 서비스 예외 처리
}
}
private String getToken(String username, List<? extends GrantedAuthority> authorities) {
// 권한 정보를 기반으로 JWT 토큰 생성
var roles = authorities.stream()
.map(GrantedAuthority::getAuthority)
.toArray(String[]::new);
return jwt.sign(Jwt.Claims.from(username, roles));
}
}
8. 다음으로 여태 만들어온 jwt 인증 관련 필터, 객체들을 빈에 등록
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
private static final Logger log = LoggerFactory.getLogger(WebSecurityConfig.class);
private final JwtConfigure jwtConfigure;
public WebSecurityConfig(JwtConfigure jwtConfigure) {
this.jwtConfigure = jwtConfigure;
}
@Bean
Jwt jwt() {
return new Jwt(jwtConfigure.getIssuer(),
jwtConfigure.getClientSecret(),
jwtConfigure.getExpirySeconds());
}
@Bean
JwtAuthenticationProvider jwtAuthenticationProvider(Jwt jwt, UserService userService) {
return new JwtAuthenticationProvider(jwt, userService);
}
@Bean
WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().requestMatchers(antMatcher("/assets/**"), antMatcher("/h2-console/**"));
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtConfigure.getHeader(), jwt());
}
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http, UserService userService) throws Exception {
AuthenticationManagerBuilder authenticationManagerBuilder =
http.getSharedObject(AuthenticationManagerBuilder.class);
authenticationManagerBuilder.authenticationProvider(jwtAuthenticationProvider(jwt(), userService));
return authenticationManagerBuilder.build();
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
/**
* spring security user 추가
*/
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers(antMatcher("/api/users/m2")).access(new HierarchyBasedAuthorizationManager(roleHierarchy()))
.anyRequest().permitAll())
.csrf(AbstractHttpConfigurer::disable)
.headers(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.rememberMe(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement((sessionManagement) -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.exceptionHandling((exceptionHandling) -> exceptionHandling
.accessDeniedHandler(accessDeniedHandler())
)
.addFilterAfter(jwtAuthenticationFilter(), SecurityContextHolderFilter.class);
return http.build();
}// 이것도 마찬가지로 변경 됏음. 과거 사용했던 메소드 밑에 현재 버전에 맞는 메소드가 있음.
@Bean
AccessDeniedHandler accessDeniedHandler() {
return (request, response, e) -> {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Object principal = authentication != null ? authentication.getPrincipal() : null;
log.warn("{} is denied", principal, e);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("text/plain");
response.getWriter().write("### Access Denied ###");
response.getWriter().flush();
response.getWriter().close();
};
}// 접근 거부 핸들러
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");
return roleHierarchy;
}/*
계층형 권한 설정 스프링 시큐리티 버전 6.0에 추가된 내용.
계층형 권한 설정을 통해 ROLE_ADMIN이 ROLE_USER보다 상위 권한이라는 것을 설정함.
*/
static class HierarchyBasedAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
private final RoleHierarchy roleHierarchy;
public HierarchyBasedAuthorizationManager(RoleHierarchy roleHierarchy) {
this.roleHierarchy = roleHierarchy;
}
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
var userAuthorities = authentication.get().getAuthorities();
var reachableAuthorities = roleHierarchy.getReachableGrantedAuthorities(userAuthorities);
var hasUserRole = reachableAuthorities.stream()
.anyMatch(auth -> "ROLE_USER".equals(auth.getAuthority()));
return new AuthorizationDecision(hasUserRole);
}
}
}
9. 로그인을 위한 컨트롤러 작성
@RestController
@RequestMapping("/api")
public class UserController {
private final UserService userService;
private final AuthenticationManager authenticationManager;
public UserController(UserService userService, AuthenticationManager authenticationManager) {
this.userService = userService;
this.authenticationManager = authenticationManager;
}
@PostMapping("/users/login")
public UserResponse login(@RequestBody loginRequest request) {
var authToken = new JwtAuthenticationToken(request.principal(), request.credentials());
var resultToken = authenticationManager.authenticate(authToken);
var authenticated = (JwtAuthenticationToken) resultToken;
var principal = (JwtAuthentication) authenticated.getPrincipal();
var user = (User) authenticated.getDetails();
return new UserResponse(principal.token, principal.username, user.getGroup().getName());
}
@GetMapping("/users/me")
public UserResponse me(@AuthenticationPrincipal JwtAuthentication authentication) {
var user = userService.findByLoginId(authentication.username);
return new UserResponse(authentication.token, authentication.username, user.getGroup().getName());
}
}
이제 jwt를 이용한 로그인이 구현이 끝났다. 다음 시간에는 oauth2 + jwt를 이용한 로그인 구현 방법을 알아보자
'Spring > Security' 카테고리의 다른 글
Spring Security 10: JWT #1 (0) | 2023.08.13 |
---|---|
Spring Security 9: Spring Session (0) | 2023.08.12 |
Spring Security 8: 데이터베이스를 이용해 인증하기(JPA) #2 (0) | 2023.08.12 |
Spring Security 7: 데이터베이스를 이용해 인증하기(JDBC) #1 (0) | 2023.08.11 |
Spring Security 6: Thread Local (0) | 2023.08.11 |