벌써 5월 마지막이다 시간 빠르다 어제 개인과제 2~4단계 진행했고 오늘은 개인과제 5~7단계 진행했다
5단계 부터는 빡세서.. 내가 구현한 코드가 정답이 아닐 수 있다
피드백이 오면 옳게 한번 리팩토링 해보겠 습니다
5️⃣단계 - JWT
기능
● JWT를 이용한 인증/인가를 구현한다.
● 위 1~4 단계에서 인증/인가가 완료된 후에만 기능이 동작하도록 수정한다
조건
● Access Token 만료시간 60분
● Refresh Token 구현은 8단계이므로 이번에는 하지 않습니다.
⚠️ 예외 처리
● StatusCode : 400, client에 반환
● 토큰이 필요한 API 요청에서 토큰을 전달하지 않았거나 정상 토큰이 아닐 때
( 에러 메세지 : 토큰이 유효하지 않습니다.)
● 토큰이 있고, 유효한 토큰이지만 해당 사용자가 작성한 게시글/댓글이 아닐 때
( 에러 메세지 : 작성자만 삭제/수정할 수 있습니다.)
● DB에 이미 존재하는 username으로 회원가입을 요청할 때
( 에러 메세지 : 중복된 username 입니다.)
● 로그인 시, 전달된 username과 password 중 맞지 않는 정보가 있을 때
( 에러 메시지 : 회원을 찾을 수 없습니다.)
StatusCode 나누기
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
public WebSecurityConfig(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService, AuthenticationConfiguration authenticationConfiguration) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
this.authenticationConfiguration = authenticationConfiguration;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
//WebSecurityConfig
/api/auth/** 경로로 시작하는 요청은 모두 허용
JWT를 사용하여 인증과 인가 과정을 필터로 구현하며, HttpSecurity를 통해 특정 API 경로의 보안을 설정
인증 필터(JwtAuthenticationFilter)와 인가 필터(JwtAuthorizationFilter)를 등록
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/signup")
public ResponseEntity<String> signup(@RequestBody SignupRequestDto requestDto) {
try {
authService.signup(requestDto);
return new ResponseEntity<>("회원가입 성공", HttpStatus.OK);
} catch (IllegalArgumentException e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
}
}
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody LoginRequestDto requestDto) {
try {
authService.login(requestDto);
return new ResponseEntity<>("로그인 성공", HttpStatus.OK);
} catch (IllegalArgumentException e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
}
}
}
//AuthController
사용자 인증과 관련된 엔드포인트를 처리
회원가입(signup)과 로그인(login) 요청을 받아 AuthService를 통해 처리
import lombok.Getter;
@Getter
public class LoginRequestDto {
private String username;
private String password;
}
@Getter
@Setter
public class SignupRequestDto {
private String username;
private String password;
private UserRoleEnum role;
public UserRoleEnum getRole() {
return role;
}
}
//dto 2개
로그인,회원가입 dto ( 로그인 정보와 회원가입 정보 받는다)
@Getter
@NoArgsConstructor
@Entity
public class User<UserRoleEnum> {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private UserRoleEnum role;
public User(String username, String password, UserRoleEnum role) {
this.username = username;
this.password = password;
this.role = role;
}
}
public enum UserRoleEnum {
USER,
ADMIN
}
//User Entity 와 UserRoleEnum
사용자 정보와 역할(user,admin)을 정의 , db 저장
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/auth/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
log.info("로그인 시도");
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getUsername(),
requestDto.getPassword(),
null
)
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
log.info("로그인 성공 및 JWT 생성");
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
UserRoleEnum role = (UserRoleEnum) ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String token = jwtUtil.createToken(username, role);
jwtUtil.addJwtToCookie(token, response);
response.getWriter().write("로그인 성공");
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
log.error("로그인 실패");
response.setStatus(400);
response.getWriter().write("회원을 찾을 수 없습니다.");
}
}
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = jwtUtil.getTokenFromRequest(request);
if (token != null) {
token = jwtUtil.substringToken(token);
if (jwtUtil.validateToken(token)) {
Claims info = jwtUtil.getUserInfoFromToken(token);
setAuthentication(info.getSubject());
} else {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("토큰이 유효하지 않습니다.");
return;
}
}
filterChain.doFilter(request, response);
}
private void setAuthentication(String username) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
//JwtAuthenticationFilter & JwtAuthorizationFilter
JwtAuthenticationFilter는 로그인 시도를 캡처하고, 유효한 자격 증명에 대해 JWT를 생성하여 클라이언트에 반환
JwtAuthorizationFilter는 요청에 포함된 JWT의 유효성을 검증하고, 요청이 계속 진행될 수 있도록 한다
@Component
public class JwtUtil {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String AUTHORIZATION_KEY = "auth";
public static final String BEARER_PREFIX = "Bearer ";
private final long TOKEN_TIME = 60 * 60 * 1000L;
@Value("${jwt.secret.key}")
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username)
.claim(AUTHORIZATION_KEY, role)
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm)
.compact();
}
public void addJwtToCookie(String token, HttpServletResponse res) {
try {
token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20");
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token);
cookie.setPath("/");
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
logger.error(e.getMessage());
}
}
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
logger.error("Not Found Token");
throw new NullPointerException("Not Found Token");
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
logger.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
public String getTokenFromRequest(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();
if(cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
try {
return URLDecoder.decode(cookie.getValue(), "UTF-8");
} catch (UnsupportedEncodingException e) {
return null;
}
}
}
}
return null;
}
}
//JwtUtil
JWT 생성, 검증, 파싱을 담당
토큰 생성 시 사용자 이름과 역할을 기반으로 토큰을 생성하고, 요청으로부터 토큰을 추출하여 검증한다
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
public User getUser() {
return user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
@Service
public class UserDetailsServiceImpl implements org.springframework.security.core.userdetails.UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Can't find " + username));
return new UserDetailsImpl(user);
}
}
//UserRepository,UserDetailsImpl,UserDetailsServceImpl
@Service
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final HttpServletResponse response;
public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtUtil jwtUtil, HttpServletResponse response) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtUtil = jwtUtil;
this.response = response;
}
public void signup(SignupRequestDto requestDto) {
String username = requestDto.getUsername();
String password = passwordEncoder.encode(requestDto.getPassword());
UserRoleEnum role = requestDto.getRole();
Optional<User> found = userRepository.findByUsername(username);
if (found.isPresent()) {
throw new IllegalArgumentException("중복된 username 입니다.");
}
User user = new User(username, password, role);
userRepository.save(user);
}
public void login(LoginRequestDto requestDto) {
User user = userRepository.findByUsername(requestDto.getUsername())
.orElseThrow(() -> new IllegalArgumentException("회원님을 찾을 수 없습니다."));
if (!passwordEncoder.matches(requestDto.getPassword(), user.getPassword())) {
throw new IllegalArgumentException("회원님을 찾을 수 없습니다.");
}
String token = jwtUtil.createToken(user.getUsername(), (UserRoleEnum) user.getRole());
jwtUtil.addJwtToCookie(token, response);
}
}
//AuthService
서비스 로직 , signup과login 메서드 통해 처리
6️⃣단계 - 회원가입
기능
● 사용자의 정보를 전달 받아 유저 정보를 저장한다
사용자 필드 | 데이터 유형 |
아이디 | bigint |
별명 | varchar |
사용자 이름 (username) | varchar |
비밀번호 (password) | varchar |
권한 (일반,어드민) | varchar |
생성일 | timestamp |
조건
● 패스워드 암호화는 하지 않습니다
● username은 최소 4자 이상, 10자 이하이며 알파벳 소문자(a~z), 숫자(0~9)로 구성되어야 한다
● password는 최소 8자 이상, 15자 이하이며 알파벳 대소문자(a~z, A~Z), 숫자(0~9)로 구성되어야 한다
● DB에 중복된 username이 없다면 회원을 저장하고 Client 로 성공했다는 메시지, 상태코드 반환하기
@Getter
@Setter
public class SignupRequestDto {
private String username;
private String password;
private String nickname;
private UserRoleEnum role;
public UserRoleEnum getRole() {
return role == null ? UserRoleEnum.USER : role;
}
}
//회원가입에 필요한 데이터 수집 dto
@Getter
@NoArgsConstructor
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -19,13 +21,21 @@ public class User<UserRoleEnum> {
@Column(nullable = false)
private String password;
@Column(nullable = false, unique = true)
private String nickname;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private UserRoleEnum role;
@Column(nullable = false)
private LocalDateTime createdAt;
public User(String username, String password, String nickname, UserRoleEnum role) {
this.username = username;
this.password = password;
this.nickname = nickname;
this.role = role;
this.createdAt = LocalDateTime.now();
}
}
//엔티티 db
@Service
public class AuthService {
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
private final HttpServletResponse response;
public AuthService(UserRepository userRepository, JwtUtil jwtUtil, HttpServletResponse response) {
this.userRepository = userRepository;
this.jwtUtil = jwtUtil;
this.response = response;
}
public void signup(SignupRequestDto requestDto) {
String username = requestDto.getUsername();
String password = requestDto.getPassword();
String nickname = requestDto.getNickname();
UserRoleEnum role = requestDto.getRole();
validateUsername(username);
validatePassword(password);
Optional<User> found = userRepository.findByUsername(username);
if (found.isPresent()) {
throw new IllegalArgumentException("중복된 username 입니다.");
}
User user = new User(username, password, nickname, role);
userRepository.save(user);
}
private void validateUsername(String username) {
String usernamePattern = "^[a-z0-9]{4,10}$";
if (!Pattern.matches(usernamePattern, username)) {
throw new IllegalArgumentException("username은 최소 4자 이상, 10자 이하이며 알파벳 소문자(a~z), 숫자(0~9)로 구성되어야 합니다.");
}
}
private void validatePassword(String password) {
String passwordPattern = "^[a-zA-Z0-9]{8,15}$";
if (!Pattern.matches(passwordPattern, password)) {
throw new IllegalArgumentException("password는 최소 8자 이상, 15자 이하이며 알파벳 대소문자(a~z, A~Z), 숫자(0~9)로 구성되어야 합니다.");
}
}
public void login(LoginRequestDto requestDto) {
User user = userRepository.findByUsername(requestDto.getUsername())
.orElseThrow(() -> new IllegalArgumentException("회원님을 찾을 수 없습니다."));
if (!user.getPassword().equals(requestDto.getPassword())) {
throw new IllegalArgumentException("회원님을 찾을 수 없습니다.");
}
String token = jwtUtil.createToken(user.getUsername(), user.getRole());
jwtUtil.addJwtToCookie(token, response);
}
}
//서비스 로직
회원가입 처리하고
조건에 맞는 에러도 처리
7️⃣단계 - 로그인
기능
● username, password 정보를 client로부터 전달받아 토큰을 반환한다
설명
● DB에서 username을 사용하여 저장된 회원의 유무를 확인한다
( 저장된 회원이 있다면 password 를 비교하여 로그인 성공 유무를 체크한다. )
조건
● 패스워드 복호화는 하지 않습니다
● 로그인 성공 시 로그인에 성공한 유저의 정보와 JWT를 활용하여 토큰을 발급한다
● 발급한 토큰을 Header에 추가하고 성공했다는 메시지 및 상태코드와 함께 client에 반환한다
7단계 까지 완료 했다 !
'TIL' 카테고리의 다른 글
TIL - 2024/06/04 (0) | 2024.06.04 |
---|---|
TIL - 2024/06/03 (0) | 2024.06.03 |
TIL - 2024/05/30 (0) | 2024.05.30 |
TIL - 2024/05/29 (2) | 2024.05.29 |
TIL - 2024/05/28 (0) | 2024.05.28 |