이번에 팀 프로젝트 로그인 구현 방식으로 JWT를 적용해봤습니다
다른 완성도 높은 방식들도 많지만 아직 완벽히 이해하진 못해서 스스로 이해 가능할 정도 수준으로 구현 했습니다😅
package구조
- 개인 패키지 이름
- config
- controller
- dto
- entity
- repository
- service
- jwt
Build.Gradle
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.13'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'com.donggram'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
maven {
url 'https://repo.clojars.org'
name 'Clojars'
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
runtimeOnly 'com.h2database:h2'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
tasks.named('test') {
useJUnitPlatform()
}
위의 'io.jsonwebtoken:~~~' dependency들은 꼭 넣어줘야 나중에 에러때문에 안 막힙니다
config/SecurityConfig.class
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic().disable()
.csrf().disable()
.headers().frameOptions().disable().and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/h2-console/**").permitAll()
.antMatchers("/api/login").permitAll()
.antMatchers("/api/join").permitAll()
// .anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, refreshTokenRepository),
UsernamePasswordAuthenticationFilter.class)
.build();
}
}
JWT방식으로 하면 세션을 사용하지 않으므로 STATELESS로 설정 해놓습니다.
headers().frameOptions().disable() : h2 DB 접근을 위해 설정 합니다.
entity/RefreshToken.class
@Getter
@Entity
@NoArgsConstructor
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
private String refreshToken;
@NotBlank
private String studentId;
public RefreshToken(String token, String studentId) {
this.refreshToken = token;
this.studentId = studentId;
}
public RefreshToken updateToken(String token) {
this.refreshToken = token;
return this;
}
}
jwt/JwtTokenProvider.class
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private String secretKey = "myprojectsecretmyprojectsecretmyprojectsecret";
// 토큰 유효시간 30분
private long tokenValidTime = 30 * 60 * 1000L;
// refreshToken 유효시간 30일
private long refreshTokenValidTime = 30 * 24 * 60 * 60 * 1000L;
private final UserDetailsService userDetailsService;
private final RefreshTokenRepository refreshTokenRepository;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
public TokenInfo generateToken(String userPk, List<String> roles){
Claims claims = Jwts.claims().setSubject(userPk);
claims.put("roles", roles);
Date now = new Date();
String accessToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidTime) )
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
String refreshToken = Jwts.builder()
.setExpiration(new Date(now.getTime() + refreshTokenValidTime))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
return TokenInfo.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
public void setHeaderAccessToken(HttpServletResponse response, String accessToken) {
response.setHeader("Access_Token", accessToken);
}
public void setHeaderRefreshToken(HttpServletResponse response, String refreshToken) {
response.setHeader("Refresh_Token", refreshToken);
}
public Authentication getAuthentication(String accessToken){
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(accessToken));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
public String getHeaderToken(HttpServletRequest request, String type) {
return type.equals("Access") ? request.getHeader("Access_Token") : request.getHeader("Refresh_Token");
}
public boolean validateToken(String jwtToken) {
try {
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken).getBody();
Date expiration = claims.getExpiration();
log.info("expiration : " + expiration);
return true;
} catch (Exception e) {
return false;
}
}
public boolean refreshTokenValidation(String token){
if(!validateToken(token)) return false;
Optional<RefreshToken> refreshToken = refreshTokenRepository.findByRefreshToken(token);
return refreshToken.isPresent() && token.equals(refreshToken.get().getRefreshToken());
}
}
∙메서드 설명
- generateToken(Stirng userPk, List<String> roles) : 로그인 시 유저의 학번, 역할 파라미터로 받은 이후 토큰 생성 - setHeaderAccessToken() : Header에 accessToken 설정
- setHeaderRefreshToken() : Header에 refreshToken 설정
- getAuthentication(String accessToken) : accessToken으로 사용자의 인증정보 가져옴
- getUserPk() : 토큰을 복호화하여 학번 추출함
- getHeaderToken() : access, refresh 중 어떤 헤더를 가져올 것인지
- validateToken() : 해당 토큰이 유효한지 판단
- refreshTokenValidation() : 받아온 토큰이 DB에 있는 토큰과 일치하는지 판단
jwt/JwtAuthenticationFilter.class
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String accessToken = jwtTokenProvider.getHeaderToken((HttpServletRequest) request, "Access");
String refreshToken = jwtTokenProvider.getHeaderToken((HttpServletRequest) request, "Refresh");
if(accessToken != null){
//access 유효한 경우
if (jwtTokenProvider.validateToken(accessToken)){
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("accessToken 유효");
}
else if(refreshToken != null){
boolean isRefreshToken = jwtTokenProvider.refreshTokenValidation(refreshToken);
// access 만료 && refresh 유효
if(isRefreshToken){
Optional<RefreshToken> refreshToken1 = refreshTokenRepository.findByRefreshToken(refreshToken);
String loginId = refreshToken1.get().getStudentId();
TokenInfo tokenInfo = jwtTokenProvider.generateToken(loginId, Collections.singletonList("ROLE_USER"));
String newAccessToken = tokenInfo.getAccessToken();
jwtTokenProvider.setHeaderAccessToken((HttpServletResponse) response, newAccessToken);
Authentication authentication = jwtTokenProvider.getAuthentication(newAccessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("accessToken만료, refreshToken 발급함");
}
// access , refresh 둘다 만료
else{
jwtExceptionHandler((HttpServletResponse) response, "RefreshToken Expired", HttpStatus.BAD_REQUEST);
log.info("accessToken,refreshToken 둘 다 만료");
return;
}
}
}
chain.doFilter(request, response);
}
public void jwtExceptionHandler(HttpServletResponse response, String msg, HttpStatus status) {
response.setStatus(status.value());
response.setContentType("application/json");
try {
String json = new ObjectMapper().writeValueAsString(new GlobalResDto(msg, status.value()));
response.getWriter().write(json);
} catch (Exception e) {
log.error(e.getMessage());
}
}
}
GenericFilterBean은 Spring Security에서 제공하는 필터를 구현하기 위한 추상 클래스 입니다. 이 클래스를 상속받아 커스텀 필터를 만들 수 있습니다. 커스텀 필터를 이용해 AccessToken,RefreshToken 검증 로직을 추가했습니다.
고려해야 할 경우가 3가지 있습니다.
1. AccessToken이 유효한 경우 : 해당 사용자를 인증
2. AccessToken 만료 && RefreshToken 유효 : RefreshToken 유효성 검사 후 새로운 AccessToken 생성
3. AccessToken && RefreshToken 모두 만료 : 오류 응답을 보냄
service/CustomUserDetailService.class
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByStudentId(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
}
}
JwtTokenProvider의 getAuthentication에서 쓰입니다.
사용자의 정보를 데이터베이스에서 가져와 Spring Security에서 사용할 수 있는 UserDetails로 반환 합니다.
service/MemberService.class
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
public ResponseDto join(SignUpDto signUpDto) throws Exception{
if(memberRepository.findByStudentId(signUpDto.getStudentId()).isPresent()){
throw new Exception("이미 가입된 학번 입니다.");
}
if(!signUpDto.getPassword().equals(signUpDto.getCheckPassword())){
throw new Exception("비밀번호가 일치 하지 않습니다.");
}
Member member = Member.builder()
.studentId(signUpDto.getStudentId())
.password(signUpDto.getPassword())
.name(signUpDto.getName())
.college1(signUpDto.getCollege1())
.college2(signUpDto.getCollege2())
.major1(signUpDto.getMajor1())
.major2(signUpDto.getMajor2())
.profile_image("NULL")
.roles(Collections.singletonList("ROLE_USER"))
.build();
member.encodePassword(passwordEncoder);
memberRepository.save(member);
return ResponseDto.builder()
.status(200)
.responseMessage("회원가입 성공")
.data("NULL")
.build();
}
public ResponseDto login(SignInDto signInDto) {
Member member = memberRepository.findByStudentId(signInDto.getStudentId())
.orElseThrow(() -> new IllegalArgumentException("가입되지 않은 학번입니다."));
TokenInfo tokenInfo = jwtTokenProvider.generateToken(signInDto.getStudentId(), member.getRoles());
//RefreshToken 있는지 확인
Optional<RefreshToken> refreshToken = refreshTokenRepository.findByStudentId(signInDto.getStudentId());
//있으면 refreshToken 업데이트
//없으면 새로 만들고 저장
if(refreshToken.isPresent()){
refreshTokenRepository.save(refreshToken.get().updateToken(tokenInfo.getRefreshToken()));
}else {
RefreshToken newToken = new RefreshToken(tokenInfo.getRefreshToken(), signInDto.getStudentId());
refreshTokenRepository.save(newToken);
}
return ResponseDto.builder()
.status(200)
.responseMessage("로그인 성공")
.data(tokenInfo)
.build();
}
}
회원가입과 로그인 로직입니다.
dto/SignInDto.class
@Data
@Builder
@AllArgsConstructor
public class SignInDto {
@NotBlank(message = "학번을 입력해주세요. ")
private String studentId;
@NotBlank(message = "비밀번호를 입력해주세요.")
private String password;
}
dto/SignUpDto.class
@Data
@Builder
@AllArgsConstructor
public class SignUpDto {
@NotBlank(message = "학번을 입력해주세요.")
private String studentId;
@NotBlank(message = "이름을 입력해주세요.")
private String name;
@NotBlank(message = "비밀번호를 입력해주세요.")
private String password;
private String checkPassword;
@NotBlank(message = "단과대를 선택해주세요.")
private String college1;
private String college2;
@NotBlank(message = "전공을 선택해주세요.")
private String major1;
private String major2;
private String role;
}
dto/ResponseDto.class
@Getter
@RequiredArgsConstructor
@Builder
public class ResponseDto {
private final int status;
private final String responseMessage;
private final Object data;
}
controller/MemberController.class
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody SignUpDto signUpDto) throws Exception {
ResponseDto responseDto = memberService.join(signUpDto);
return ResponseEntity.ok(responseDto);
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody SignInDto signInDto) throws Exception {
ResponseDto responseDto = memberService.login(signInDto);
return ResponseEntity.ok(responseDto);
}
}
entity/Member.class
@Getter
@AllArgsConstructor
@Builder
@Entity
@NoArgsConstructor
public class Member extends BaseTimeEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(nullable = false)
private String studentId;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String college1;
@Column
private String college2;
@Column(nullable = false)
String major1;
@Column
String major2;
@Column
String profile_image;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() {
return name;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public void encodePassword(PasswordEncoder passwordEncoder){
this.password = passwordEncoder.encode(password);
}
}
repository/MemberRepository.interface
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByStudentId(String student_id);
Optional<Member> findByName(String username);
}
repository/RefreshTokenRepository.interface
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByStudentId(String studentId);
Optional<RefreshToken> findByRefreshToken(String token);
}
PostMan 테스트 결과
'개발' 카테고리의 다른 글
[자바 ORM 표준 JPA 프로그래밍] 영속성 관리 (0) | 2023.09.10 |
---|---|
[Spring Boot] jwt로 로그인, 회원가입 API 구현 (0) | 2023.07.07 |
[Spring Boot] Security + jwt로 인증, 인가 구현하기 (1) | 2023.07.06 |
session, cookie, jwt 관계 및 정리 (1) | 2023.07.01 |
[Spring Boot] 회원 정보 조회 및 삭제 (0) | 2023.02.12 |