
📝 요구사항
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에 반환하기
회원 정보를 담는 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=) : 정규식을 만족하는가?
@Size(min= , max = ) : 문자열, 배열 등의 크기가 만족하는가?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단이 아닌 객체에서 들어오는 값에 대해 검증을 할 수 있다.
userService의 validateUser로 username과 password를 보내서 인증한다. tokenMakerService의 createToken로 토큰을 가져온다.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로 선언하여 비밀번호 암호화 한 뒤 전송&비교할 예정
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;
}
}
}
토큰을 발급받는 프로세스는 이렇다.
JWT토큰을 생성해서 반환Authoriztion헤더에 포함하여 이후의 요청을 보냄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를 사용한다.
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검증하기