사용자 인증 방식은 일반적으로 세션 기반 방식과 토큰 기반 방식(JWT)이 있습니다.
각 방식의 장단점이 있어 상황에 맞게 결정하여 사용하는 것이 중요합니다.
지난 팀 프로젝트의 경우에는 서버 사이드 렌더링 방식이기 때문에 세션 방식을 사용해 구현했었는데요.
이번 개인 프로젝트에서는 클라이언트 사이드 렌더링 방식으로 한 웹 페이지이기 때문에 확장성을 고려하여 토큰 기반 방식으로 구현해보려고 합니다.
한참을 공부하고 헤매이면서 구현한 과정이라
추후에 필요한 부분은 수정, 또 수정을 거쳐서 상세히 기록해보려고 합니다.
1️⃣ 라이브러리 설정
Spring Security와 JWT를 사용하기 위해 다음 라이브러리들을 추가해줍니다.
build.gradle
dependencies {
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
2️⃣ JwtToken DTO 생성
클라이언트에 토큰을 보내기 위해 JwtToken DTO를 생성합니다.
JwtToken.java
package com.example.demo.jwt;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Builder;
@Builder
@Data
@AllArgsConstructor
public class JwtToken {
private String grantType;
private String accessToken;
private String refreshToken;
}
- grantType: JWT의 인증 타입을 나타내는 문자열 필드입니다.
- accessToken: 접근 토큰을 나타내는 문자열 필드입니다.
- refreshToken: 갱신 토큰을 나타내는 문자열 필드입니다.
- grantType은 OAuth 2.0과 같은 인증 프로토콜에서 사용되는 용어이며, 클라이언트가 서버로부터 액세스 토큰을 요청할 때 사용되는 인증 유형을 지정합니다. 일반적으로 OAuth 2.0의 grantType 중 하나로는 "authorization_code", "password", "client_credentials", "refresh_token" 등이 있습니다.
- Bearer 인증 방식은 JWT의 토큰을 HTTP 요청의 Authorization 헤더에 포함하여 전송하는 방식을 의미합니다.
3️⃣ application.properties 추가
jwt.secret=qwrmlasfmlqw2491u4291urjeiqowfjkwklfnlksdnfi13fnou3nounf13
4️⃣ JwtTokenProvider
JSON Web Token (JWT) 을 생성하고 유효성을 검사하는 데 사용되는 클래스 입니다.
사용자가 로그인하거나 권한이 필요한 엔드포인트에 액세스할 때 JWT를 생성하고 제공하며, 나중에 클라이언트로부터 제출된 JWT를 사용하여 사용자를 인증하고 권한을 확인합니다.
이 클래스에서 JWT 토큰의 생성, 복호화, 검증 기능을 구현했습니다.
package com.example.demo.jwt;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j // 롬복을 이용하여 로깅을 위한 Logger 선언
@Component
public class JwtTokenProvider {
private final Key key; // JWT 서명을 위한 Key 객체 선언
// @Autowired
// private RefreshTokenInfoRedisRepository refreshTokenInfoRepository; // RefreshToken 정보를 저장하기 위한 Repository
// 생성자를 통한 JWT 서명용 Key 초기화
// application.property에서 secret 값 가져와서 key에 저장
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey); // Base64로 인코딩된 Secret Key 디코딩
this.key = Keys.hmacShaKeyFor(keyBytes); // Secret Key를 이용하여 Key 객체 생성
}
// 유저 정보를 이용하여 AccessToken과 RefreshToken을 생성하는 메서드
public JwtToken generateToken(Authentication authentication) {
// 권한 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime(); // 현재 시각 가져오기
Date issuedAt = new Date(); // 토큰 발급 시각
//Header 부분 설정
Map<String, Object> headers = new HashMap<>();
headers.put("alg", "HS256");
headers.put("typ", "JWT");
// Access Token 생성
String accessToken = Jwts.builder()
.setHeader(createHeaders()) // Header 부분 설정
.setSubject("accessToken") // 토큰 주제 설정
.claim("iss", "off") // 토큰 발급자 설정
.claim("aud", authentication.getName()) // 토큰 대상자 설정
.claim("auth", authorities) // 사용자 권한 설정
.setExpiration(new Date(now + 1800000)) // 토큰 만료 시간 설정 (30분)
.setIssuedAt(issuedAt) // 토큰 발급 시각 설정
.signWith(key, SignatureAlgorithm.HS256) // 서명 알고리즘 설정
.compact(); // 토큰 생성
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setHeader(createHeaders()) // Header 부분 설정
.setSubject("refreshToken") // 토큰 주제 설정
.claim("iss", "off") // 토큰 발급자 설정
.claim("aud", authentication.getName()) // 토큰 대상자 설정
.claim("auth", authorities) // 사용자 권한 설정
.claim("add", "ref") // 추가 정보 설정
.setExpiration(new Date(now + 604800000)) // 토큰 만료 시간 설정 (7일)
.setIssuedAt(issuedAt) // 토큰 발급 시각 설정
.signWith(key, SignatureAlgorithm.HS256) // 서명 알고리즘 설정
.compact(); // 토큰 생성
// TokenInfo 객체 생성 및 반환
return JwtToken.builder()
.grantType("Bearer") // 토큰 타입 설정
.accessToken(accessToken) // Access Token 설정
.refreshToken(refreshToken) // Refresh Token 설정
.build(); // TokenInfo 객체 생성
}
// JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내 Authentication 객체를 생성하는 메서드
public Authentication getAuthentication(String token) {
// Jwt 토큰 복호화
Claims claims = parseClaims(token);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 가져오기
Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication return
// UserDetails: interface, User: UserDetails를 구현한 class
UserDetails principal = new User((String) claims.get("aud"), "", authorities);
// UsernamePasswordAuthenticationToken 객체 반환
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
// JWT 토큰의 유효성을 검증하는 메서드
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); // 토큰 파싱하여 유효성 검증
return true; // 유효한 토큰일 경우 true 반환
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
throw new ApiException(ExceptionEnum.INVALID_TOKEN); // 토큰이 잘못된 경우 예외 처리
} catch (ExpiredJwtException e) {
throw new ApiException(ExceptionEnum.TIMEOUT_TOKEN); // 토큰이 만료된 경우 예외 처리
} catch (UnsupportedJwtException | IllegalArgumentException e) {
throw new ApiException(ExceptionEnum.INVALID_TOKEN); // 지원하지 않는 토큰이거나 잘못된 형식의 경우 예외 처리
} catch (Exception e){
throw new ApiException(ExceptionEnum.INVALID_TOKEN); // 그 외 예외 처리
}
}
// RefreshToken을 이용하여 AccessToken을 재발급하는 메서드
public JwtToken refreshToken(String refreshToken) {
try {
// Refresh Token 복호화
Authentication authentication = getAuthentication(refreshToken);
// Redis에 저장된 Refresh Token 정보 가져오기
RefreshTokenInfo redisRefreshTokenInfo = refreshTokenInfoRepository.findById(authentication.getName()).orElseThrow();
JwtToken refreshGetToken = null;
// Redis에 저장된 Refresh Token과 요청된 Refresh Token이 일치할 경우
if (refreshToken.equals(redisRefreshTokenInfo.getRefreshToken())) {
refreshGetToken = generateToken(authentication); // 토큰 재발급
saveToken(refreshGetToken, authentication); // Redis에 새로운 Refresh Token 정보 저장
return refreshGetToken; // 새로운 토큰 반환
} else {
log.warn("does not exist Token"); // Redis에 저장된 Refresh Token이 존재하지 않을 경우
throw new ApiException(ExceptionEnum.TOKEN_DOES_NOT_EXIST); // 해당 예외 처리
}
} catch (NullPointerException e) {
log.warn("does not exist Token"); // Refresh Token이 존재하지 않을 경우
throw new ApiException(ExceptionEnum.TOKEN_DOES_NOT_EXIST); // 해당 예외 처리
} catch (SignatureException e) {
log.warn("Invalid Token Info"); // 토큰 정보가 잘못된 경우
throw new ApiException(ExceptionEnum.INVALID_TOKEN_INFO); // 해당 예외 처리
} catch (NoSuchElementException e) {
log.warn("no such Token value"); // Redis에 해당 토큰이 존재하지 않을 경우
throw new ApiException(ExceptionEnum.TOKEN_DOES_NOT_EXIST); // 해당 예외 처리
}
}
// JWT 토큰을 파싱하여 클레임 정보를 반환하는 메서드
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody(); // 토큰 파싱하여 클레임 정보 반환
} catch (ExpiredJwtException e) {
return e.getClaims(); // 만료된 토큰의 경우 클레임 정보 반환
}
}
// JWT 토큰의 Header 정보를 생성하는 메서드
private static Map<String, Object> createHeaders() {
Map<String, Object> headers = new HashMap<>();
headers.put("alg", "HS256"); // 알고리즘 정보 설정
headers.put("typ", "JWT"); // 토큰 타입 정보 설정
return headers; // 생성된 Header 정보 반환
}
}
💚 JwtTokenProvider()
application.properties에서 secret 값을 가져와 key에 저장하는 메서드 입니다.
입력된 시크릿 키는 Base64로 인코딩되어 있으며, 이를 디코딩하여 HMAC 알고리즘을 사용하여 키 객체를 생성합니다.
💚 generateToken()
- 인증(Authentication) 객체를 기반으로 Access Token과 Refresh Token 생성
- Access Token: 인증된 사용자의 권한 정보와 만료 시간을 담고 있는 토큰
- Refresh Token: Access Token의 갱신을 위해 사용되는 토큰
💚 getAuthentication()
- 주어진 Access token을 복호화하여 사용자의 인증 정보(Authentication)를 생성
- 토큰의 Claims에서 권한 정보를 추출하고, User 객체를 생성하여 Authentication 객체로 반환
💚 validateToken()
- 주어진 토큰을 검증하여 유효성을 확인
- Jwts.parserBuilder를 사용하여 토큰의 서명 키를 설정하고, 예외 처리를 통해 토큰의 유효성 여부를 판단
💚 parseClaims()
- 클레임(claims): 토큰에서 사용할 정보의 조각
- 주어진 Access token을 복호화하고, 만료된 토큰의 경우에도 Claims 반환
- parseClaimsJws() 메서드가 JWT 토큰의 검증과 파싱을 모두 수행
5️⃣ JwtAuthenticationFilter 구현
Spring Security에서 사용되는 필터 중 하나로, JWT 토큰을 사용하여 사용자의 인증을 처리하는 역할을 합니다.
- HTTP 요청의 인증
클라이언트가 HTTP 요청을 보낼 때, JwtAuthenticationFilter는 헤더나 요청의 다른 부분에서 JWT 토큰을 추출합니다. - JWT 토큰의 유효성 검사
추출된 JWT 토큰의 유효성을 검사합니다. 토큰의 서명을 확인하고 만료 여부 등을 확인하여 유효한 토큰인지 확인합니다. - 사용자 인증
유효한 JWT 토큰이 확인되면 해당 토큰에서 사용자 정보를 추출합니다. 이를 기반으로 Spring Security의 Authentication 객체를 생성합니다. - 인증된 사용자의 보안 컨텍스트 설정
Authentication 객체를 SecurityContext에 저장하여 현재 사용자의 인증 정보를 설정합니다. 이렇게 함으로써 사용자는 보호된 엔드포인트에 액세스할 수 있습니다.
package com.example.demo.jwt;
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.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider; // JWT 토큰을 처리하기 위한 JwtTokenProvider 객체
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException, IOException {
// Request Header에서 JWT 토큰 추출
String token = resolveToken(request);
// 토큰 유효성 검사 후 인증 처리
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효할 경우 인증 객체 생성
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext에 인증 객체 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 다음 필터로 이동
filterChain.doFilter(request, response);
}
// Request Header에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // "Bearer " 부분 제거하고 토큰 반환
}
return null;
}
}
💚 doFilter()
- resolveToken() 메서드를 사용하여 요청 헤더에서 JWT 토큰을 추출
- JwtTokenProvider의 validateToken() 메서드로 JWT 토큰의 유효성 검증
- 토큰이 유효하면 JwtTokenProvider의 getAuthentication() 메서드로 인증 객체 가져와서 SecurityContext에 저장 ➡︎ 요청을 처리하는 동안 인증 정보가 유지됩니다.
- chain.doFilter()를 호출하여 다음 필터로 요청을 전달
💚 resolveToken()
- 주어진 HttpServletRequest에서 토큰 정보를 추출하는 역할
- "Authorization" 헤더에서 "Bearer" 접두사로 시작하는 토큰을 추출하여 반환
6️⃣ SecurityConfig 설정
Spring Security의 설정을 담당하는 SecurityConfig를 작성합니다.
// (버전에 따라 변경된 부분) 기본 로그인 폼을 사용하지 않도록 설정
.formLogin((form) -> form.disable())
// 로그아웃은 아래 코드 말고 따로 설정할거임.
.logout((logout) -> logout.permitAll()); // 로그아웃 허용
// 필터 통해서 토큰 기반 로그인을 할거다.
// UsernamePasswordAuthenticationFilter 이전에 JwtAuthenticationFilter가 실행되도록 등록
http.addFilterBefore(new JwtAuthenticationFilter(JwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtExceptionFilter(), JwtAuthenticationFilter.class);
전체 코드
package com.example.demo.config;
import com.example.demo.jwt.JwtAuthenticationFilter;
import com.example.demo.jwt.JwtExceptionFilter;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import java.util.Collections;
@Configuration
@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터체인에 등록됨.
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Collections.singletonList("http://localhost:3000/"));
config.setAllowedMethods(Collections.singletonList("*"));
config.setAllowCredentials(true);
config.setAllowedHeaders(Collections.singletonList("*"));
config.setMaxAge(3600L); //1시간
return config;
}
}));
// csrf 토큰 없이 요청하면 해당 요청을 막기 때문에 잠깐 비활성화
http.csrf(csrf -> csrf.disable())
// 인증절차 설정 시작 => 특정 URL에 대한 권한 설정.
.authorizeHttpRequests((requests) -> requests
// 모두 허용
// "/api/v1/auth/n/**" 라고 설정해줄 수도 있음. => 사용자 토큰 없이(n) 접근 허용.
.requestMatchers("/api/v1/auth/n/**").permitAll()
// 관리자=선생님(ROLE_ADMIN)만 접근 허용
// "/api/v1/auth/admin/**" 라고 설정해줄 수도 있음.
//.requestMatchers("/admin/**").hasRole("ADMIN") => ROLE_ 접두사가 자동으로 들어감.
.requestMatchers("/admin/**").hasAnyAuthority("ROLE_ADMIN")
// 로그인한 사용자(ROLE_USER)만 접근 허용
// "/api/v1/auth/y/**" 라고 설정해줄 수도 있음.
.requestMatchers("/api/v1/auth/y/**").hasAnyAuthority("ROLE_USER")
// TODO: 최고 권한의 관리자 필요함. ( 필수는 아님. 추후 고려해보기. )
// 나머지 요청은 인증된 사용자에게만 접근 허용
.anyRequest().authenticated()
)
// (버전에 따라 변경된 부분) 기본 로그인 폼을 사용하지 않도록 설정
.formLogin((form) -> form.disable())
// 로그아웃은 아래 코드 말고 따로 설정할거임.
.logout((logout) -> logout.permitAll()); // 로그아웃 허용
// 필터 통해서 토큰 기반 로그인을 할거다.
// UsernamePasswordAuthenticationFilter 이전에 JwtAuthenticationFilter가 실행되도록 등록
http.addFilterBefore(new JwtAuthenticationFilter(JwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtExceptionFilter(), JwtAuthenticationFilter.class);
return http.build();
}
}
💚 필터를 통해서 토큰 기반 로그인을 하겠다고 알리는 부분
addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class): JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가하여 JWT 인증을 처리 ( UsernamePasswordAuthenticationFilter 이전에 JwtAuthenticationFilter가 실행되도록 등록 )
7️⃣ 인증을 위한 Domain, Repository 설정
UserDetails interface를 구현합니다.
package com.example.demo.entity;
import jakarta.persistence.*;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
@Entity(name = "user")
@Getter @Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_Id", nullable = false, length = 30, unique = true)
private String userId;
@Column(name = "password", nullable = false, length = 50)
private String password;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "birth", nullable = false)
private String birth;
@Column(name = "email", nullable = false)
private String email;
@Column(name = "addr1", nullable = true)
private String addr1;
@Column(name = "addr2", nullable = true)
private String addr2;
// PuppyEntity 일대다 관계 추가
@OneToMany(cascade = CascadeType.ALL)
private List<PuppyEntity> puppyEntityList;
// CommentEntity 일대다 관계 추가 & 조인
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "id") // 외래키 지정
private List<CommentEntity> commentEntityList;
// BoardEntity 일대다 관계 추가 & 조인
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "id")
private List<BoardEntity> boardEntityList;
// LikeListEntity 일대다 관계 추가 & 조인
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "id")
private List<LikeListEntity> likeListEntityList;
/**
* 해당 유저의 권한 목록
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
/**
* 사용자의 비밀번호
*/
@Override
public String getPassword() {
return password;
}
/**
* PK값
*/
@Override
public String getUsername() {
return userId;
}
/**
* 계정 만료 여부
* true : 만료 안됨
* false : 만료
* @return
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 계정 잠김 여부
* true : 잠기지 않음
* false : 잠김
* @return
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 비밀번호(인증정보) 만료 여부
* true : 만료 안됨
* false : 만료
* @return
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 사용자 활성화 여부
* true : 활성화
* false : 비활성화
* @return
*/
@Override
public boolean isEnabled() {
return true;
}
}
'Project 댕린이집' 카테고리의 다른 글
[트러블슈팅] org.springframework.security.authentication.InternalAuthenticationServiceException (0) | 2024.04.08 |
---|---|
[회원가입] 권한(Role) 추가 (0) | 2024.03.28 |
[회원가입] 비밀번호 확인 (0) | 2024.03.06 |
[회원가입] 유효성 검사 (0) | 2024.03.05 |
[회원가입] ResDTO 이용한 아이디 중복 확인 (0) | 2024.03.04 |