오늘은 로그인 기능과 관련된 내용을 작성하였다.
JWT 관련 내용도 포함되어있어서 복습해야하는 내용이 꽤 많았는데 코드를 작성하면서 헷갈렸던 부분이 좀 더 명확해진 느낌이다.
먼저 JWT에 대해 다시 한번 정리를 해보면
JWT란 JSON Web Token의 약자로 JSON 포맷의 토큰 정보를 인코딩하고, 인코딩된 토큰 정보를 Secret Key로 서명한 것을 Web Token으로 사용하는 것을 말한다. (흐름을 "정보 -> 인코딩 -> 서명"으로 이해할 수 있다.)
Access Token과 Refresh Token으로 구분되는데 Access Token은 실제로 권한을 얻는데 사용하는 토큰이며 Refresh Token은 Access Token의 유효기간이 만료됐을 때 새로운 Access Token을 발급받는데 사용한다.(다시 로그인할필요 없음)
Token은 클라이언트측에 저장되므로 서버에서 임의로 삭제할 수 없다. 따라서 유효기간을 설정해서 관리한다.
요청 및 처리 흐름을 간단하게 정리해보았다.
* (괄호)안에 명시되어있는 Filter명, Service명은 내용에 따라 변경할 수 있다.
그림에 나타난대로 클라이언트가 로그인하기위한 정보(ID, PW)를 보내면 Security Filter(JwtAuthenticationFilter)가 이를 받아서 처리한다. JwtAuthenticationFilter에서 어떤 엔드포인트로 요청왔을때 해당 로직을 수행할지 설정할 수 있다.
인증처리를 위임받은 AuthenticationManager에서 UserDetailsService(MemberDetailsService)로 사용자 UserDetails 조회를 위임한다. UserDetailsService는 DB에서 사용자 정보를 조회하고, 그 정보를 다시 AuthenticationManager에게 전달한다.
AuthenticationManager가 전달받은 UserDetails와 로그인 정보(ID, PW)가 일치하는지 확인한다.
인증이 정상 처리되면 JWT를 생성하고 Response Header에 해당 내용을 담아서 클라이언트에게 응답하게된다.
해당 로직을 수행하기 위한 코드를 아래와 같이 작성하였다.
팀원들과 의견 공유를 위해 관련 내용을 주석으로 설명해두었다.
[로그인 기능 구현]
1. 의존 라이브러리 설정: JWT 기능을 위한 jjwt 라이브러리를 추가하였다.
dependencies {
(...)
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
2. JwtTokenizer: JWT를 생성하기 위한 코드이다.
비밀키, 토큰 유효시간 등은 application.yml 파일에서 설정하였다.
비밀키를 받아서 인코딩하고, 반대로 인코딩된 비밀키에서 키를 리턴하는 메서드를 포함한다.
또한 AccessToken, RefreshToken을 생성하는 메서드를 포함한다.
verifySignature를 통해 JWT 검증 기능을 추가하였다.
@Component
public class JwtTokenizer {
// 서명에 사용할 비밀키 설정. 보안과 관련된 내용은 환경변수를 통해 가져온다.
@Getter
@Value("${jwt.key}")
private String secretKey;
@Getter
@Value("${jwt.access-token-expiration-minutes}")
private int accessTokenExpirationMinutes;
@Getter
@Value("${jwt.refresh-token-expiration-minutes}")
private int refreshTokenExpirationMinutes;
// Plain Text 형태의 비밀키를 Base64 형식 문자열로 인코딩하는 메서드 (jjwt에서 plain text를 비밀키로 사용하는것을 권장하지 않음)
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
// AccessToken 생성하는 메서드
// base64 인코딩된 비밀키를 사용해 Key 객체를 얻고, 해당 Key로 JWT에 서명한다.
// claims에는 주로 사용자 관련 정보가 들어간다.
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
// RefreshToken 생성하는 메서드.
// AccessToken 발행을 위한 토큰이므로 구체적인 사용자 정보는 포함할 필요 없다.
public String generateRefreshToken(String subject,
Date expiration,
String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
// JWT에서 Claims(사용자 정보)를 가져오는 메서드
public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
return claims;
}
// JWT에 포함된 signiture를 검증하는 메서드.
// JWT의 위변조 여부를 확인할 수 있다.
// jws는 signature를 포함한 JWT라는 의미이다. (sign 완료된 jwt -> jws로 표현)
public void verifySignature(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
}
// 서명에 사용할 Secret Key를 생성해주는 메서드.
// Base64로 인코딩된 비밀키를 디코딩해서 HMAC 알고리즘을 적용한 Key객체를 생성한다.
// HMAC 알고리즘이란, Hash-based Message Authentication Code로 해시함수와 비밀키를 사용해 인증 코드를 생성하는 방법이다.
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
Key key = Keys.hmacShaKeyFor(keyBytes);
return key;
}
// JWT의 만료 일시를 지정하기 위한 메서드. JWT 생성시 사용
public Date getTokenExpiration(int expirationMinutes){
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, expirationMinutes);
Date expiration = calendar.getTime();
return expiration;
}
}
3. Custom UserDetailsService(MemberDetailsService): 데이터베이스에서 사용자 크리덴셜 조회 후 조회한 크리덴셜을
AuthenticatoinManager에게 전달하는 클래스이다.
UserDetailsService 인터페이스를 구현하는데, UserDetailsService는 사용자 정보를 가져오는데 사용되는 인터페이스이다.
MemberRepository에서 관련 정보를 가져와야하므로 DI 적용하였다.
@Component
@Slf4j
public class MemberDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
public MemberDetailsService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
// User(Member) 이름을 통해 로그인 대상 찾는 메서드, 일치하는 회원 없으면 예외 던짐
// UserDetails 객체는 사용자 상세 정보를 나타낸다. 사용자 인증정보(비밀번호)나 권한 등이 포함될 수 있다.
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member findMember = memberRepository.findByLoginId(username);
if(findMember == null) throw new RuntimeException();
log.info("# DB에서 User LoginId 조회 완료");
return new MemberDetails(findMember);
}
// UserDetails를 구현한 내부 클래스 정의, 현재는 권한과 관련된 내용은 없음
private final class MemberDetails extends Member implements UserDetails{
public MemberDetails(Member member) {
setMemberId(member.getMemberId());
setLoginId(member.getLoginId());
setPwd(member.getPwd());
setPersonalInfo(member.getPersonalInfo());
setMemberStatus(member.getMemberStatus());
setName(member.getName());
}
// 권한 설정에 대한 내용 추가 (현재는 관리자 / 사용자 등 권한 구분 없음)
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return getPwd();
}
@Override
public String getUsername() {
return getLoginId();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
}
4. MemberLoginDto: 로그인 정보를 담는 Dto 객체를 정의한다.
@Getter
public class MemberLoginDto {
private String loginId;
private String pwd;
}
5. JwtAuthenticationFilter: 클라이언트 로그인 인증 정보를 받는 엔트리 포인트 역할을 하는 필터이다.
UsernamePasswordAuthenticationFilter를 상속받아서 아이디 / 패스워드 기반의 인증을 처리하도록 한다.ㅎ
필터를 정의한 뒤 SecurityConfiguration에 추가해서 필터를 적용시킨다.
AuthenticationManager가 MemberDetailsService에게 관련 업무를 위임하고 인증 관련 로직을 수행하므로
AuthenticationManager가 필요하다.(DI)
로그인 인증 완료시 JWT를 발급해야하므로 JwtTokenizer가 필요하다.(DI)
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtTokenizer jwtTokenizer;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
this.authenticationManager = authenticationManager;
this.jwtTokenizer = jwtTokenizer;
}
// 인증 시도하는 메서드. 기존에 UsernamePasswordAuthenticationFilter에 존재하는 메서드를 Override함
// .getInputStream()은 예외처리를 해주어야하는 메서드인데,
// @SneakyThrows를 붙이면 해당 메서드에서 발생할 수 있는 예외를 명시적으로 처리하지 않아도 된다.
// 컴파일러가 발생할 수 있는 예외를 확인하고 예외를 던지는 코드를 자동 추가한다.(이 애너테이션 없으면 try catch 사용해야함)
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
// 클라이언트가 전송한 id, pw를 DTO 클래스로 역질렬화 한다. ObjectMapper를 통해 LoginDto로 역직렬화 한다.
ObjectMapper objectMapper = new ObjectMapper();
MemberLoginDto loginDto = objectMapper.readValue(request.getInputStream(), MemberLoginDto.class);
// id와 pw 정보를 포함한 UsernamePasswordAuthenticationToken을 생성한다.(인증 안된 상태)
// UsernamePasswordAuthenticationToken을 AuthenticationManager에게 전달해서 인증 처리를 위임한다.
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getLoginId(), loginDto.getPwd());
return authenticationManager.authenticate(authenticationToken);
}
// 인증 성공했을때 수행하는 메서드. 기존에 AbstractAuthenticationProcessingFilter에 존재하는 메서드를 Override함
// 사용자에게 액세스 토큰과 리프레시 토큰을 발행하고 응답 헤더에 설정한다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) {
// Principal은 인증된 사용자 정보를 나타낸다. Authentication에서 주체(Principal) 정보를 가져온다.
Member member = (Member) authResult.getPrincipal();
// 인증된 사용자 정보를 바탕으로 액세스 토큰, 리프레시 토큰을 생성한다.
String accessToken = delegateAccessToken(member);
String refreshToken = delegateRefreshToken(member);
// Response Header에 토큰 정보를 설정한다.
response.setHeader("Authorization", "Bearer " + accessToken);
response.setHeader("Refresh", refreshToken);
}
private String delegateAccessToken(Member member) {
// accessToken에 필요한 정보를 담아 생성한다.
Map<String, Object> claims = new HashMap<>();
claims.put("loginId", member.getLoginId());
String subject = member.getLoginId();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
private String delegateRefreshToken(Member member){
// refreshToken은 액세스 토큰 만료시 토큰 생성용이므로 claim은 필요 없다.
String subject = member.getLoginId();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
return refreshToken;
}
}
6. SecurityConfiguration: 작성한 filter를 Spring Security Filter Chain에 추가해서 로그인 인증을 처리하게 한다.
@Configuration
public class SecurityConfigurationV2 { // 로그인 구현된 설정파일을 Version2로 명시
// JWT 생성을 위해 JwtTokenizer DI 해준다.
private final JwtTokenizer jwtTokenizer;
public SecurityConfigurationV2(JwtTokenizer jwtTokenizer) {
this.jwtTokenizer = jwtTokenizer;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.cors(withDefaults()) // corsConfigurationSource를 사용해 CORS 설정 추가
.csrf().disable() // 로컬에서 테스트 예정이므로 csrf 비활성화 (활성화 할 경우 403에러 발생)
.formLogin().disable() // JSON 포맷으로 id, pw를 전송하므로 formLogin 비활성화
.httpBasic().disable() // HTTP Basic 인증은 요청 전송시마다 id, pw를 header에 실어서 인증하는 방식. 현재 프로젝트에선 사용하지 않음.
.apply(new CustomFilterConfigurer()) // 사용자 정의 필터 추가
.and()
.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()); // role 설정 전이므로 모든 요청 허용으로 설정함. 이 설정이 맨 앞으로가면 모든 요청이 승인됨
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET","POST", "PATCH", "DELETE"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
// AbstractHttpConfigurer를 상속하는 타입과 HttpSecurityBuilder를 상속하는 타입을 제너릭 타입으로 지정 가능.
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity>{
// SecurityConfigurerAdapter의 configure 메서드를 오버라이딩해서 Configuration을 커스터마이징 할 수 있음
@Override
public void configure(HttpSecurity builder) throws Exception {
// getSharedObject()를 통해 spring Security 설정 구성하는 SecurityConfigurer간에 공유되는 객체 얻을 수 있음
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
jwtAuthenticationFilter.setFilterProcessesUrl("/login"); // 디폴트 request URL을 /login으로 셋팅
builder.addFilter(jwtAuthenticationFilter); // addFilter를 통해 Security 필터 체인에 추가함.
}
}
}
[결과 확인]
회원 정보를 등록하고 로그인아이디, 비밀번호를 /login 경로로 전송하였다.
응답 헤더에 Authorization, Refresh로 JWT가 설정되어있는것을 확인할 수 있다.
클라이언트가 요청을 보낼때 해당 JWT를 헤더에 포함하면 서버에서 전달받은 JWT를 검증해서 유효한 요청인지 확인할 수 있다.
참고자료
2023.05.16 - [부트캠프 개발일기/Spring Security] - 부트캠프 65일차 - 자격 증명 / JWT 인증
'부트캠프 개발일기 > Pre-Project' 카테고리의 다른 글
88일차: Pre-Project Day 8-2 (JWT 로그아웃, Redis) (2) | 2023.06.20 |
---|---|
88일차: Pre-Project Day 8-1 (JWT 검증) (0) | 2023.06.20 |
86일차: Pre-Project Day 6 (회원가입 기능 구현, 암호화) (0) | 2023.06.16 |
85일차: Pre-Project Day 5 (이슈 설정, 역할 분담) (0) | 2023.06.15 |
84일차: Pre-Project Day 4 (사용자 요구사항 정의서, ERD) (0) | 2023.06.14 |