[Spring] JWT토큰 발급하여 로그인,회원가입 기능 구현하기(1)

nana·2024년 9월 15일
0

Spring

목록 보기
3/9
post-thumbnail

📝 요구사항

1. 회원 가입 API

- username, password를 Client에서 전달받기
- username은  `최소 4자 이상, 10자 이하이며 알파벳 소문자(a~z), 숫자(0~9)`로 구성되어야 한다.
- password는  `최소 8자 이상, 15자 이하이며 알파벳 대소문자(a~z, A~Z), 숫자(0~9)`로 구성되어야 한다.
- DB에 중복된 username이 없다면 회원을 저장하고 Client 로 성공했다는 메시지, 상태코드 반환하기  

2. 로그인 API

- username, password를 Client에서 전달받기
- DB에서 username을 사용하여 저장된 회원의 유무를 확인하고 있다면 password 비교하기
- 로그인 성공 시, 로그인에 성공한 유저의 정보와 JWT를 활용하여 토큰을 발급하고, 
발급한 토큰을 Header에 추가하고 성공했다는 메시지, 상태코드 와 함께 Client에 반환하기

Entity

회원 정보를 담는 USER_INFO테이블은
seqNo / userName / password / reg_id / reg_date / reg_ip 로 이루어 져 있다.
비밀번호 수정에 대한 요구사항이 없었기에 edit에 관련된 정보는 따로 넣지 않았다.

import jakarta.persistence.*;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.springframework.web.bind.annotation.GetMapping;

@Entity
@Data //getter, setter 자동생성
@Table(name="USER_INFO")
public class UserInfo {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name="seq_no")
    private Integer seqNo;

    @Column(name="user_name", nullable = false, unique = true)
    @Pattern(regexp = "[0-9a-z]+", message="Username Must Contain Only Lowered letters and Numbers")
    @Size(min=4, max=10, message="size 4~10")
    private String userName;

    @Column(nullable = false)
    @Pattern(regexp = "[0-9a-zA-Z]+", message="Username Must Contain Only Letters and Numbers")
    @Size(min=8, max=15, message="size 8~15")
    private String password;

    @Column(name= "reg_id")
    private String regId;

    @Column(name= "reg_date")
    private String regDate;

    @Column(name="reg_ip")
    private String regIp;

}

@GeneratedValue :기본 키를 자동으로 생성해주는 어노테이션이다. 속성으로 strategy 가 있으며 이를 통해 자동 생성 전략을 지정해줄 수 있다. (JPA기본키 매핑해줌)
@Pattern(regex=) : 정규식을 만족하는가?

  • message 옵션을 통해 패턴과 다른 문자열이 들어갔을 경우 뱉어내는 메시지를 설정할 수 있다.
    @Size(min= , max = ) : 문자열, 배열 등의 크기가 만족하는가?
    이외의 java.validation의 어노테이션 설명은 아래 참고링크를 통해 확인.

UserController

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@Slf4j
@RequestMapping("/users")
public class UserController {

    @Autowired
    private UserService userService;
    @Autowired
    private TokenMakerService tokenMakerService;

    @PostMapping("/register")
    public ResponseEntity<String> registerUser(@RequestBody @Valid UserInfo user){
        log.info("Registering user: {}", user.getUserName());
        
        if (userService.registerUser(user).equals("REQUEST_SUCCESS")) {
            log.info("User registered successfully: {}", user.getUserName());
            
            return ResponseEntity.ok( "User registered successfully!");
        } else {
            log.warn("Username already exists: {}", user.getUserName());
            
            return ResponseEntity.badRequest().body("Username "+user.getUserName()+" already exists!");
        }
    }
    @PostMapping("/login")
    public ResponseEntity<?> userLogin(@RequestBody UserInfo user){
        log.info("Login attempt for user: {}", user.getUserName());

        //사용자 인증 처리
        if(userService.validateUser(user.getUserName(), user.getPassword())){
            String token = tokenMakerService.createToken(user.getUserName());
            log.info("Login Success");
            
            return ResponseEntity.ok(new JwtResponse(token));
        }else{
            log.warn("Invalid username or password for user: {}", user.getUserName());
            
            return ResponseEntity.status(401).body("Invalid username or password");
        }

    }

}

@Valid : service단이 아닌 객체에서 들어오는 값에 대해 검증을 할 수 있다.

  1. 등록시에는 따로 인증 없고 로그인 시에 userServicevalidateUser로 username과 password를 보내서 인증한다.
  2. 인증에 성공하면 tokenMakerServicecreateToken로 토큰을 가져온다.

UserService

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Optional;


@Service
@Slf4j
public class UserService {


    private final UserInfoRepository userRepository;
    private final BCryptPasswordEncoder passwordEncoder;

    public UserService(UserInfoRepository userRepository, BCryptPasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public String registerUser(UserInfo user){
        String clientIp = "";


        log.info("User registration started for: {}", user.getUserName());
        log.info("Login attempt for user: {}", user.getUserName());
        if(userRepository.findOneByUserName(user.getUserName()) != null)
            return "EXISTS_USER";

        DateFormat now = new SimpleDateFormat("YYYY-MM-dd HH:mm:ss");
        user.setRegDate(now.format(new Date()));
        user.setRegIp(clientIp);

        userRepository.save(user);
        return "REQUEST_SUCCESS"; //회원가입 성공
        //비밀번호 암호화 한 뒤 전송 > 비교
    }

    public boolean validateUser(String username, String rawPassword) {
        Optional<UserInfo> optionalUser = userRepository.findByUserName(username);
        if (optionalUser.isPresent()) {
            UserInfo user = optionalUser.get();
            log.info("User found: {}", username);
            log.info("Comparing passwords: rawPassword = {}, storedPassword = {}", rawPassword, user.getPassword());
            return rawPassword.equals(user.getPassword());  // 평문 비밀번호 비교
        } else {
            log.warn("User not found: {}", username);
            return false;
        }
    }
}

BCryptPasswordEncoder : 암호화 하는 클래스이지만 JPA에서 @Valid사용하려면 평문으로 저장해야하기에 사용하지않았다.
추후 리팩토링시에 DTO로 선언하여 비밀번호 암호화 한 뒤 전송&비교할 예정

TokenMakerService

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;

@Slf4j
@Component
public class TokenMakerService {
    /**
     *  토큰 발급받기
     *  1. 사용자가 로그인 정보를 서버에 전송.
     *  2. 서버는 로그인 정보가 유효하면 JWT토큰을 생성해서 반환
     *  3. 클라이언트는 서버로부터 받은 JWT토큰을 Authoriztion헤더에 포함하여 이후의 요청을 보냄
     *  4. 서버는 각 요청에서 JWT토큰을 검증해서 인증 처리.
     *  */
    private final long accessTokenExpMilliseconds = 3600000;
    private int refreshTokenExpMinutes = 100;

    private SecretKey secretKey;

    public TokenMakerService(@Value("${spring.jwt.secret}") String secret){ //Window에서 설정 방법 : setx JWT_SECRET "시크릿 키" / Linux&maxOS :  export JWT_SECRET="시크릿키"
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); // 1)
    }
// 2) void가 있어서 생성자가아닌 매서드로 인식
    public String createToken(String username){
        Date now = new Date();
        Date validity = new Date(now.getTime() + accessTokenExpMilliseconds);

        return Jwts.builder()
                .setSubject(username) //주체설정
                .setIssuedAt(now)//토큰 발행 시간
                .setExpiration(validity) // 토큰 만료 시간
                .signWith(secretKey) // 비밀키로 서명
                .compact(); //JWT생성

    }

    // JWT 토큰에서 사용자 이름(Subject) 가져오기
    public String getUsername(String token) {
        String username = Jwts.parserBuilder()  // 최신 버전에서는 parserBuilder() 사용
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJwt(token)
                .getBody()
                .getSubject();

        log.debug("your token : {} / your name : {}",token,username);
        return username;  // JWT의 주체(사용자 이름) 반환
    }

    public boolean validateToken(String token){
        try{
            Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJwt(token);
            log.info("This token is verified : {}",token);
            return true;
        }catch(Exception e){
            log.error("Invalid Token {}",token);
            return false;
        }
    }
}

토큰을 발급받는 프로세스는 이렇다.

  1. 사용자가 로그인 정보를 서버에 전송.
  2. 서버는 로그인 정보가 유효하면 JWT토큰을 생성해서 반환
  3. 클라이언트는 서버로부터 받은 JWT토큰을 Authoriztion헤더에 포함하여 이후의 요청을 보냄
  4. 서버는 각 요청에서 JWT토큰을 검증해서 인증 처리.

1) Keys.hmacShaKeyFor() : key byte array를 기반으로 적절한 HMAC 알고리즘을 적용한 Key(java.security.Key) 객체를 생성
2) (헤맴포인트) 아무리 디버깅을 해도 로그인 시에 403에러가 뜨는 이유를 못 찾았다.

application.yml에도 설정이 잘 되어있는데 @Value 어노테이션으로 못 받아와서 팀원의 도움을 구했다.
원인은

습관성 리턴타입 지정 ㅋㅋ,,,으로 인해 해당 코드를 생성자가 아닌 신규 매서드로 인식했고, 그래서 secret key를 받아오지 못한 것으로 추측된다.
void를 삭제하니 정상적으로 잘 추출되었다.

회원가입이 정상적으로 되는데 로그인이 안되는게 말이 안됐다..

3) (헤맴포인트) implementation 'io.jsonwebtoken:jjwt-api:0.11.5' 버전을 사용하고 있는데 최신 버전에서는 Jwts.parserBuilder를 사용한다.

SecurityConfig

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.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {
    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws  Exception{
        http
              //  .csrf().disable() //CSRF 비활성화(JWT 사용 시 주로 비활성화)
              //  .authorizeRequests()  >> 더이상 이 두개 사용되지않음.
                .csrf(csrf->csrf.disable()) // CSRF비활성화  .csrf().disable() 대신 람다식을 사용.
                .authorizeHttpRequests(auth -> auth  //authorizeRequests 대신 authorizeHttpRequests 사용.
                        .requestMatchers("/users/register", "/users/login", "/css/**", "/js/**").permitAll() //회원가입이랑 로그인은 허용하고! css나 js도 허용
                        .anyRequest().authenticated() // 그 외의 모든 요청은 인증 필요.
                )
                .sessionManagement(session->session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); //세션을 사용하지 않음 (JWT기반인증)


        return http.build();  //필터 체인 구성 후 반환환
    }
}

java의 security에 대해 도무지 알 수가 없어서 며칠을 디버깅하며 소스를 지웠다 썼다 했는지 모르겠다.
토큰을 사용할 때 SecurityFilterChain을 Override하여 인증 예외처리를 시켜줘야 한다는 것을 배웠다.

처음 보는 구현 방식인데 사용되지 않는 매서드도 줄줄이라 정말 환장할 노릇 ㅋㅋ
.requestMatchers 안에 예외처리 시켜 줄 url을 넣으면 된다고 한다.
css랑 js파일은 메모 겸 넣어 놓은 것.

역시 인증하는 곳이 제일 오래 걸린다. CURD는 금방 하는듯..

👉🏻결과

  • 회원가입

    => 성공

=> 실패(정규식 위반), 실패 메시지는 실행 창에서 확인할 수 있다.

  • 로그인

https://mangkyu.tistory.com/174
https://hamait.tistory.com/342 : 정규표현식
https://bamdule.tistory.com/35 : @Valid 어노테이션으로 Parameter검증하기

profile
BackEnd Developer, 기록의 힘을 믿습니다.

0개의 댓글