[TIL] 230530 <Spring> 회원가입, 로그인 기능이 있는 투두앱 백엔드 서버 만들기 (2)
- -
[Validation 어노테이션 활용하여 예외처리]
build.gradle>dependencies에 아래 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'
dto>CommentRequestDto
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CommentRequestDto {
@NotNull(message = "일정의 ID가 입력되지 않았습니다.")
private Long scheduleId;
@NotBlank(message = "댓글의 내용이 비어있습니다.")
private String content;
@NotBlank(message = "작성자 ID가 입력되지 않았습니다.")
private String userId;
}
controller>CommentController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class CommentController {
private final CommentService commentService;
// 댓글 작성
@PostMapping("/comment")
public CommentResponseDto createComment(@Valid @RequestBody CommentRequestDto requestDto) {
return commentService.createComment(requestDto);
}
// 댓글 수정
@PutMapping("/comment/{commentId}")
public CommentResponseDto updateComment(@PathVariable Long commentId, @Valid @RequestBody CommentRequestDto requestDto) {
return commentService.updateComment(commentId, requestDto);
}
// 댓글 삭제
@DeleteMapping("/comment/{commentId}")
public ResponseEntity<Response> deleteComment(@PathVariable Long commentId, @RequestBody CommentRequestDto requestDto) {
return commentService.deleteComment(commentId, requestDto);
}
}
service>CommentService
@Service
@RequiredArgsConstructor
public class CommentService {
private final CommentRepository commentRepository;
private final ScheduleRepository scheduleRepository;
public CommentResponseDto createComment(CommentRequestDto requestDto) {
Schedule schedule = findScheduleById(requestDto.getScheduleId());
Comment comment = new Comment(requestDto.getContent(), requestDto.getUserId(), schedule);
commentRepository.save(comment);
return new CommentResponseDto(comment);
}
@Transactional
public CommentResponseDto updateComment(Long commentId, CommentRequestDto requestDto) {
checkCommentIdNull(commentId); // 댓글 Id 입력받았는지 확인
findScheduleById(requestDto.getScheduleId()); // 선택한 일정이 DB에 저장되어 있는지 확인
// 댓글 ID로 댓글 조회 및 사용자 ID로 권한 확인
Comment comment = findCommentByIdAndUserId(commentId, requestDto.getUserId());
// 댓글 내용이 변경되었는지 확인하고 변경된 경우에만 업데이트
String newContent = requestDto.getContent();
if (!newContent.equals(comment.getContent())) {
comment.setContent(newContent);
commentRepository.save(comment);
}
return new CommentResponseDto(comment);
}
public ResponseEntity<Response> deleteComment(Long commentId, CommentRequestDto requestDto) {
checkCommentIdNull(commentId); //댓글 Id 입력받았는지 확인
findScheduleById(requestDto.getScheduleId()); // 선택한 일정이 DB에 저장되어 있는지 확인
// 댓글 ID로 댓글 조회 및 사용자 ID로 권한 확인
Comment comment = findCommentByIdAndUserId(commentId, requestDto.getUserId());
commentRepository.delete(comment); // 댓글 삭제
Response response = new Response(HttpStatus.OK.value(), "댓글이 성공적으로 삭제되었습니다.");
return ResponseEntity.ok(response); // 성공 메시지와 상태 코드 반환
}
// 댓글 ID 유효성 검사 메소드
private void checkCommentIdNull(Long commentId) {
if (commentId == null) {
throw new IllegalArgumentException("댓글의 ID가 입력되지 않았습니다.");
}
}
// 선택한 일정이 DB에 저장되어 있는지 확인하는 메소드
private Schedule findScheduleById(Long scheduleId) {
return scheduleRepository.findById(scheduleId)
.orElseThrow(() -> new NoSuchElementException("선택한 일정을 찾을 수 없습니다."));
}
// 선택한 댓글이 DB에 저장되어 있는지 확인하는 메소드
private Comment findCommentById(Long commentId) {
return commentRepository.findById(commentId)
.orElseThrow(() -> new NoSuchElementException("선택한 댓글을 찾을 수 없습니다."));
}
// 댓글 ID로 댓글 조회 및 사용자 ID로 권한 확인 메소드
private Comment findCommentByIdAndUserId(Long commentId, String userId) {
Comment comment = findCommentById(commentId); // 선택한 댓글이 DB에 저장되어 있는지 확인
if (!comment.getUserId().equals(userId)) {
throw new IllegalArgumentException("선택한 댓글의 사용자가 현재 사용자와 일치하지 않습니다.");
}
return comment;
}
}
[회원가입]
기능
- 사용자의 정보를 전달 받아 유저 정보를 저장한다.사용자 필드 데이터 유형
아이디 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 로 성공했다는 메시지, 상태코드 반환하기
entity>User
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "users")
public class User extends Timestamped{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
@Size(min = 4, max = 10, message = "username은 최소 4자 이상, 10자 이하이어야 합니다.")
@Pattern(regexp = "^[a-z0-9]*$", message = "username은 알파벳 소문자(a~z), 숫자(0~9)로만 구성되어야 합니다.")
private String username;
@Column(nullable = false, unique = true)
private String nickname;
@Size(min = 8, max = 15, message = "password는 최소 8자 이상, 15자 이하이어야 합니다.")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,15}$", message = "password는 알파벳 대소문자(a~z, A~Z), 숫자(0~9)로만 구성되어야 합니다.")
@Column(nullable = false)
private String password;
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private UserRoleEnum role;
@OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
private List<Schedule> schedules = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
private List<Comment> comments = new ArrayList<>();
public User(String username, String nickname, String password, UserRoleEnum role) {
this.username = username;
this.nickname = nickname;
this.password = password;
this.role = role;
}
}
entity>UserRoleEnum
public enum UserRoleEnum {
USER(Authority.USER), // 사용자 권한
ADMIN(Authority.ADMIN); // 관리자 권한
private final String authority;
UserRoleEnum(String authority) {
this.authority = authority;
}
public String getAuthority() {
return this.authority;
}
public static class Authority {
public static final String USER = "ROLE_USER";
public static final String ADMIN = "ROLE_ADMIN";
}
}
dto>SignupRequestDto
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class SignupRequestDto {
@NotBlank(message = "사용자명이 입력되지 않았습니다.")
private String username;
@NotBlank(message = "별명이 입력되지 않았습니다.")
private String nickname;
@NotBlank(message = "비밀번호가 입력되지 않았습니다.")
private String password;
private boolean admin = false;
private String adminToken = "";
}
controller>UserController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class UserController {
private final UserService userService;
@PostMapping("/user/signup")
public ResponseEntity<Response> signup(@Valid @RequestBody SignupRequestDto requestDto) {
return userService.signup(requestDto);
}
}
service>UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
// ADMIN_TOKEN
private final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";
public ResponseEntity<Response> signup(SignupRequestDto requestDto) {
String username = requestDto.getUsername();
String password = requestDto.getPassword();
// 회원 중복 확인
Optional<User> checkUsername = userRepository.findByUsername(username);
if (checkUsername.isPresent()) {
throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
}
// 별명 중복확인
String nickname = requestDto.getNickname();
Optional<User> checkNickname = userRepository.findByNickname(nickname);
if (checkNickname.isPresent()) {
throw new IllegalArgumentException("중복된 별명입니다.");
}
// 사용자 ROLE 확인
UserRoleEnum role = UserRoleEnum.USER;
if (requestDto.isAdmin()) {
if (!ADMIN_TOKEN.equals(requestDto.getAdminToken())) {
throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
}
role = UserRoleEnum.ADMIN;
}
// 사용자 등록
User user = new User(username, nickname, password, role);
userRepository.save(user);
Response response = new Response(HttpStatus.OK.value(), "회원가입이 성공적으로 완료되었습니다.");
return ResponseEntity.ok(response); // 성공 메시지와 상태 코드 반환
}
}
repository>UserRepository
public interface UserRepository extends JpaRepository<User,Long> {
Optional<User> findByUsername(String username);
Optional<User> findByNickname(String nickname);
}
[로그인 : JWT]
기능
- username, password 정보를 client로부터 전달받아 토큰을 반환한다.
- JWT를 이용한 인증/인가를 구현한다.
설명
- DB에서 username을 사용하여 저장된 회원의 유무를 확인한다.
- 저장된 회원이 있다면 password 를 비교하여 로그인 성공 유무를 체크한다.
조건
- 패스워드 복호화는 하지 않습니다.
- 로그인 성공 시 로그인에 성공한 유저의 정보와 JWT를 활용하여 토큰을 발급한다.
- 발급한 토큰을 Header에 추가하고 성공했다는 메시지 및 상태코드와 함께 client에 반환한다.
- Access Token 만료시간 60분
⚠️ 예외 처리
- 공통조건
- StatusCode : 400
- client에 반환
- 토큰이 필요한 API 요청에서 토큰을 전달하지 않았거나 정상 토큰이 아닐 때
- 에러 메세지 : 토큰이 유효하지 않습니다.
- 토큰이 있고, 유효한 토큰이지만 해당 사용자가 작성한 게시글/댓글이 아닐 때
- 에러 메세지 : 작성자만 삭제/수정할 수 있습니다.
- DB에 이미 존재하는 username으로 회원가입을 요청할 때
- 에러 메세지 : 중복된 username 입니다.
- 로그인 시, 전달된 username과 password 중 맞지 않는 정보가 있을 때
- 에러 메시지 : 회원을 찾을 수 없습니다.
- StatusCode 나누기
build.gradle>dependencies에 아래 추가
// JWT
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
application.properties에 추가
jwt.secret.key=7Iqk7YyM66W07YOA7L2U65Sp7YG065+9U3ByaW5n6rCV7J2Y7Yqc7YSw7LWc7JuQ67mI7J6F64uI64ukLg==
dto>LoginRequestDto
@Setter
@Getter
public class LoginRequestDto {
@NotBlank(message = "사용자명이 입력되지 않았습니다.")
private String username;
@NotBlank(message = "비밀번호가 입력되지 않았습니다.")
private String password;
}
controller>UserController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class UserController {
private final UserService userService;
@PostMapping("/user/login")
public ResponseEntity<Response> login(@Valid @RequestBody LoginRequestDto requestDto, HttpServletResponse res) {
return userService.login(requestDto, res);
}
}
service>UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
public ResponseEntity<Response> login(LoginRequestDto requestDto, HttpServletResponse res) {
String username = requestDto.getUsername();
String password = requestDto.getPassword();
// 사용자 확인
User user = userRepository.findByUsername(username).orElseThrow(
() -> new NotFoundException("등록된 사용자가 없습니다.")
);
// 비밀번호 확인
if (!password.equals(user.getPassword())) {
throw new InvalidPasswordException("비밀번호가 일치하지 않습니다.");
}
String token = jwtUtil.createToken(user.getUsername(), user.getRole());
// JWT를 헤더에 추가
res.addHeader(JwtUtil.AUTHORIZATION_HEADER, token);
Response response = new Response(HttpStatus.OK.value(), "로그인이 성공적으로 완료되었습니다.");
return ResponseEntity.ok(response);
}
}
dto>CommentRequestDto
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CommentRequestDto {
@NotNull(message = "일정의 ID가 입력되지 않았습니다.")
private Long scheduleId;
@NotBlank(message = "댓글의 내용이 비어있습니다.")
private String content;
}
jwt>JwtUtil
@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {
//JWT 데이터
// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
public static final String BEARER_PREFIX = "Bearer ";
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
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);
}
//JWT 생성 (토큰 생성)
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
//header 에서 JWT 가져오기
public String getJwtFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
//JWT 토큰 검증
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;
}
//JWT에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}
controller>CommentController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class CommentController {
private final CommentService commentService;
private final JwtUtil jwtUtil;
// 댓글 작성
@PostMapping("/comment")
public ResponseEntity<Response> createComment(@Valid @RequestBody CommentRequestDto requestDto, HttpServletRequest req) {
String token = jwtUtil.getJwtFromHeader(req);
if (token != null && jwtUtil.validateToken(token)) {
Claims claims = jwtUtil.getUserInfoFromToken(token);
String username = claims.getSubject();
commentService.createComment(username, requestDto);
Response response = new Response(HttpStatus.OK.value(), "댓글이 성공적으로 작성되었습니다.");
return ResponseEntity.ok(response); // 성공 메시지와 상태 코드 반환
} else {
Response response = new Response(HttpStatus.UNAUTHORIZED.value(), "토큰이 유효하지 않습니다.");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
}
}
// 댓글 수정
@PutMapping("/comment/{commentId}")
public ResponseEntity<Response> updateComment(@PathVariable Long commentId, @Valid @RequestBody CommentRequestDto requestDto, HttpServletRequest req) {
String token = jwtUtil.getJwtFromHeader(req);
if (token != null && jwtUtil.validateToken(token)) {
Claims claims = jwtUtil.getUserInfoFromToken(token);
String username = claims.getSubject();
commentService.updateComment(commentId, username, requestDto);
Response response = new Response(HttpStatus.OK.value(), "댓글이 성공적으로 수정되었습니다.");
return ResponseEntity.ok(response); // 성공 메시지와 상태 코드 반환
} else {
Response response = new Response(HttpStatus.UNAUTHORIZED.value(), "토큰이 유효하지 않습니다.");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
}
}
// 댓글 삭제
@DeleteMapping("/comment/{commentId}")
public ResponseEntity<Response> deleteComment(@PathVariable Long commentId, @RequestBody CommentRequestDto requestDto, HttpServletRequest req) {
String token = jwtUtil.getJwtFromHeader(req);
if (token != null && jwtUtil.validateToken(token)) {
Claims claims = jwtUtil.getUserInfoFromToken(token);
String username = claims.getSubject();
commentService.deleteComment(commentId, username, requestDto);
Response response = new Response(HttpStatus.OK.value(), "댓글이 성공적으로 삭제되었습니다.");
return ResponseEntity.ok(response); // 성공 메시지와 상태 코드 반환
} else {
Response response = new Response(HttpStatus.UNAUTHORIZED.value(), "토큰이 유효하지 않습니다.");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
}
}
}
service>CommentService
@Service
@RequiredArgsConstructor
public class CommentService {
private final CommentRepository commentRepository;
private final ScheduleRepository scheduleRepository;
private final UserRepository userRepository;
public void createComment(String username, CommentRequestDto requestDto) {
Schedule schedule = findScheduleById(requestDto.getScheduleId());
User user = userRepository.findByUsername(username).orElseThrow(
() -> new NotFoundException("등록된 사용자가 없습니다.")
);
Comment comment = new Comment(requestDto.getContent(), user, schedule);
commentRepository.save(comment);
}
@Transactional
public void updateComment(Long commentId, String username, CommentRequestDto requestDto) {
checkCommentIdNull(commentId); // 댓글 Id 입력받았는지 확인
findScheduleById(requestDto.getScheduleId()); // 선택한 일정이 DB에 저장되어 있는지 확인
// 댓글 ID로 댓글 조회 및 사용자 ID로 권한 확인
Comment comment = findCommentByIdAndUserId(commentId, username);
// 댓글 내용이 변경되었는지 확인하고 변경된 경우에만 업데이트
String newContent = requestDto.getContent();
if (!newContent.equals(comment.getContent())) {
comment.setContent(newContent);
commentRepository.save(comment);
}
}
public void deleteComment(Long commentId, String username, CommentRequestDto requestDto) {
checkCommentIdNull(commentId); //댓글 Id 입력받았는지 확인
findScheduleById(requestDto.getScheduleId()); // 선택한 일정이 DB에 저장되어 있는지 확인
// 댓글 ID로 댓글 조회 및 사용자 ID로 권한 확인
Comment comment = findCommentByIdAndUserId(commentId, username);
commentRepository.delete(comment); // 댓글 삭제
}
// 댓글 ID 유효성 검사 메소드
private void checkCommentIdNull(Long commentId) {
if (commentId == null) {
throw new IllegalArgumentException("댓글의 ID가 입력되지 않았습니다.");
}
}
// 선택한 일정이 DB에 저장되어 있는지 확인하는 메소드
private Schedule findScheduleById(Long scheduleId) {
return scheduleRepository.findById(scheduleId)
.orElseThrow(() -> new NotFoundException("선택한 일정을 찾을 수 없습니다."));
}
// 선택한 댓글이 DB에 저장되어 있는지 확인하는 메소드
private Comment findCommentById(Long commentId) {
return commentRepository.findById(commentId)
.orElseThrow(() -> new NotFoundException("선택한 댓글을 찾을 수 없습니다."));
}
// 댓글 ID로 댓글 조회 및 사용자 ID로 권한 확인 메소드
private Comment findCommentByIdAndUserId(Long commentId, String username) {
Comment comment = findCommentById(commentId); // 선택한 댓글이 DB에 저장되어 있는지 확인
if (!comment.getUser().getUsername().equals(username)) {
throw new IllegalArgumentException("작성자만 삭제/수정할 수 있습니다.");
}
return comment;
}
}
[예외처리 부분 수정사항]
exception>InvalidTokenException
public class InvalidTokenException extends RuntimeException {
public InvalidTokenException(String message){
super(message);
}
}
exception>GlobalExceptionHandler
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<Response> handleNotFoundException(NotFoundException ex) {
Response response = new Response(HttpStatus.NOT_FOUND.value(), ex.getMessage());
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(InvalidPasswordException.class)
public ResponseEntity<Response> handleInvalidPasswordException(InvalidPasswordException ex) {
Response response = new Response(HttpStatus.UNAUTHORIZED.value(), ex.getMessage());
return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED);
}
@ExceptionHandler(InvalidTokenException.class)
public ResponseEntity<Response> handleInvalidTokenException(InvalidTokenException ex) {
Response response = new Response(HttpStatus.UNAUTHORIZED.value(), ex.getMessage());
return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED);
}
}
controller>CommentController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class CommentController {
private final CommentService commentService;
private final JwtUtil jwtUtil;
// 댓글 작성
@PostMapping("/comment")
public ResponseEntity<Response> createComment(@Valid @RequestBody CommentRequestDto requestDto, HttpServletRequest req) {
String token = jwtUtil.getJwtFromHeader(req);
if (token != null && jwtUtil.validateToken(token)) {
Claims claims = jwtUtil.getUserInfoFromToken(token);
String username = claims.getSubject();
commentService.createComment(username, requestDto);
Response response = new Response(HttpStatus.OK.value(), "댓글이 성공적으로 작성되었습니다.");
return ResponseEntity.ok(response); // 성공 메시지와 상태 코드 반환
} else {
throw new InvalidTokenException("토큰이 유효하지 않습니다.");
}
}
// 댓글 수정
@PutMapping("/comment/{commentId}")
public ResponseEntity<Response> updateComment(@PathVariable Long commentId, @Valid @RequestBody CommentRequestDto requestDto, HttpServletRequest req) {
String token = jwtUtil.getJwtFromHeader(req);
if (token != null && jwtUtil.validateToken(token)) {
Claims claims = jwtUtil.getUserInfoFromToken(token);
String username = claims.getSubject();
commentService.updateComment(commentId, username, requestDto);
Response response = new Response(HttpStatus.OK.value(), "댓글이 성공적으로 수정되었습니다.");
return ResponseEntity.ok(response); // 성공 메시지와 상태 코드 반환
} else {
throw new InvalidTokenException("토큰이 유효하지 않습니다.");
}
}
// 댓글 삭제
@DeleteMapping("/comment/{commentId}")
public ResponseEntity<Response> deleteComment(@PathVariable Long commentId, @RequestBody CommentRequestDto requestDto, HttpServletRequest req) {
String token = jwtUtil.getJwtFromHeader(req);
if (token != null && jwtUtil.validateToken(token)) {
Claims claims = jwtUtil.getUserInfoFromToken(token);
String username = claims.getSubject();
commentService.deleteComment(commentId, username, requestDto);
Response response = new Response(HttpStatus.OK.value(), "댓글이 성공적으로 삭제되었습니다.");
return ResponseEntity.ok(response); // 성공 메시지와 상태 코드 반환
} else {
throw new InvalidTokenException("토큰이 유효하지 않습니다.");
}
}
}
[Refresh Token]
기능
- 5단계에서 구현한 JWT를 Refresh Token을 사용하도록 변경
설명
- 세션 관리
- 자주 로그인을 반복하지 않으면서 긴 유효 기간을 가진 Refresh Token을 통해 사용자가 시스템에 지속적으로 연결되어 있을 수 있습니다.
- 리소스 관리
- 서버가 세션 상태를 유지할 필요 없습니다. 클라이언트 측에서 JWT를 관리하기 때문에 서버는 세션을 위해 추가적인 자원을 사용하지 않아도 됩니다.
- 여기서 클라이언트 → Postman
조건
- Access Token 유효기간이 지난 후 Refresh Token 갱신하지 않으면 접근 불가
- Refresh Token 만료가 되면 인증 실패 처리 및 재로그인 유도
- 스프링 시큐리티 사용하지 않고 5단계에서 구현한 JWT 기능에 Refresh Token만 추가 구현
jwt>JwtUtil
@Slf4j(topic = "JwtUtil")
@Component
@RequiredArgsConstructor
public class JwtUtil {
//JWT 데이터
// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String REFRESH_HEADER = "Refresh-Token";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
public static final String BEARER_PREFIX = "Bearer ";
// 토큰 만료시간
// private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
private final long ACCESS_TOKEN_TIME = 60 * 60 * 1000L; // 15분
private final long REFRESH_TOKEN_TIME = 7 * 24 * 60 * 60 * 1000L; // 7일
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 로그 설정
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
private final UserRepository userRepository;
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
//JWT 생성 (토큰 생성)
public String createAccessToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + ACCESS_TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
public String createRefreshToken(String username) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username)
.setExpiration(new Date(date.getTime() + REFRESH_TOKEN_TIME))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm)
.compact();
}
public String generateNewAccessToken(String refreshToken) {
Claims claims = getUserInfoFromToken(refreshToken);
String username = claims.getSubject();
User user = userRepository.findByUsername(username).orElseThrow(
() -> new NotFoundException("등록된 사용자가 없습니다.")
);
return createAccessToken(username, user.getRole());
}
public String checkToken(String accessToken, String refreshToken) {
if (!validateToken(refreshToken)) {
// Refresh Token 만료로 인한 인증 실패 처리 및 재로그인 유도
throw new InvalidTokenException("Refresh Token이 만료되었거나 유효하지 않습니다. 다시 로그인하세요.");
}
if (!validateToken(accessToken)) {
// Refresh Token을 사용하여 새로운 Access Token 생성
return generateNewAccessToken(refreshToken);
} else {
// Refresh Token과 Access Token이 유효한 경우
return accessToken;
}
}
// 토큰 헤더에서 Access Token을 가져오는 메서드
public String getAccessTokenFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
// 토큰 헤더에서 Refresh Token을 가져오는 메서드
public String getRefreshTokenFromHeader(HttpServletRequest request) {
String refreshToken = request.getHeader(REFRESH_HEADER);
if (StringUtils.hasText(refreshToken) && refreshToken.startsWith(BEARER_PREFIX)) {
return refreshToken.substring(7);
}
return null;
}
//JWT 토큰 검증
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;
}
//JWT에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}
controller>CommentController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class CommentController {
private final CommentService commentService;
private final JwtUtil jwtUtil;
// 댓글 작성
@PostMapping("/comment")
public ResponseEntity<Response> createComment(@Valid @RequestBody CommentRequestDto requestDto, HttpServletRequest req) {
// Access Token과 Refresh Token을 각각의 헤더에서 가져옴
String accessToken = jwtUtil.getAccessTokenFromHeader(req);
String refreshToken = jwtUtil.getRefreshTokenFromHeader(req);
String newAccessToken = jwtUtil.checkToken(accessToken, refreshToken);
Claims claims = jwtUtil.getUserInfoFromToken(newAccessToken);
String username = claims.getSubject();
// 댓글 생성 또는 수정
commentService.createComment(username, requestDto);
// 응답 반환
Response response = new Response(HttpStatus.OK.value(), "댓글이 성공적으로 등록되었습니다.");
return ResponseEntity.ok(response);
}
// 댓글 수정
@PutMapping("/comment/{commentId}")
public ResponseEntity<Response> updateComment(@PathVariable Long commentId, @Valid @RequestBody CommentRequestDto requestDto, HttpServletRequest req) {
// Access Token과 Refresh Token을 각각의 헤더에서 가져옴
String accessToken = jwtUtil.getAccessTokenFromHeader(req);
String refreshToken = jwtUtil.getRefreshTokenFromHeader(req);
String newAccessToken = jwtUtil.checkToken(accessToken, refreshToken);
Claims claims = jwtUtil.getUserInfoFromToken(newAccessToken);
String username = claims.getSubject();
// 댓글 생성 또는 수정
commentService.updateComment(commentId, username, requestDto);
// 응답 반환
Response response = new Response(HttpStatus.OK.value(), "댓글이 성공적으로 수정되었습니다.");
return ResponseEntity.ok(response);
}
// 댓글 삭제
@DeleteMapping("/comment/{commentId}")
public ResponseEntity<Response> deleteComment(@PathVariable Long commentId, @RequestBody CommentRequestDto requestDto, HttpServletRequest req) {
// Access Token과 Refresh Token을 각각의 헤더에서 가져옴
String accessToken = jwtUtil.getAccessTokenFromHeader(req);
String refreshToken = jwtUtil.getRefreshTokenFromHeader(req);
String newAccessToken = jwtUtil.checkToken(accessToken, refreshToken);
Claims claims = jwtUtil.getUserInfoFromToken(newAccessToken);
String username = claims.getSubject();
// 댓글 삭제
commentService.deleteComment(commentId, username, requestDto);
// 응답 반환
Response response = new Response(HttpStatus.OK.value(), "댓글이 성공적으로 삭제되었습니다.");
return ResponseEntity.ok(response);
}
}
service>UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
public ResponseEntity<Response> login(LoginRequestDto requestDto, HttpServletResponse res) {
String username = requestDto.getUsername();
String password = requestDto.getPassword();
// 사용자 확인
User user = userRepository.findByUsername(username).orElseThrow(
() -> new NotFoundException("등록된 사용자가 없습니다.")
);
// 비밀번호 확인
if (!password.equals(user.getPassword())) {
throw new InvalidPasswordException("비밀번호가 일치하지 않습니다.");
}
String accessToken = jwtUtil.createAccessToken(user.getUsername(), user.getRole());
String refreshToken = jwtUtil.createRefreshToken(user.getUsername());
res.addHeader(JwtUtil.AUTHORIZATION_HEADER, accessToken);
res.addHeader(JwtUtil.REFRESH_HEADER, refreshToken);
Response response = new Response(HttpStatus.OK.value(), "로그인이 성공적으로 완료되었습니다.");
return ResponseEntity.ok(response);
}
}
[일정과 댓글 수정]
기능
- 로그인 한 사용자에 한하여 일정과 댓글을 작성하고 수정할 수 있는 기능
- ‘만료되지 않은 유효 토큰’인 경우에만 일정과 댓글 ‘생성’이 가능하도록 변경
- 조회는 누구나 할 수 있습니다!
설명
- 보안
- 사용자 인증을 통해 접근을 제어하여 보안을 강화합니다. 또한 개인 로그를 적재하고 분석하면 모니터링도 가능합니다.
- 사용자 경험
- 로그인을 통해 개인을 식별하고 개인에 맞춘 서비스를 제공할 수 있습니다. 이를 통해 사용자 경험을 향상시킬 수 있습니다. 또한 사용자 추적을 통해 맞춤 서비스를 개발할 수 있습니다.
조건
- 로그인을 하지 않으면 기능을 사용할 수 없다.
- 유효한 토큰인 경우에만 일정과 댓글을 작성할 수 있다.
- 일정을 생성한 사용자와 동일한 username이면 수정할 수 있다.
- 댓글을 작성한 사용자와 동일한 username이면 수정할 수 있다.
entity>Schedule
@Entity // JPA가 관리할 수 있는 Entity 클래스 지정
@Getter
@Setter
@Table(name = "schedule") // 매핑할 테이블의 이름을 지정
@NoArgsConstructor
public class Schedule extends Timestamped{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", nullable = false, length = 500)
private String content;
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private User user;
@OneToMany(mappedBy = "schedule", cascade = CascadeType.REMOVE)
private List<Comment> commentList = new ArrayList<>();
public Schedule(String title, String content, User user) {
this.title = title;
this.content = content;
this.user = user;
}
public void update(String title, String content, User user) {
this.title = title;
this.content = content;
this.user = user;
}
}
dto>ScheduleRequestDto
@Getter
public class ScheduleRequestDto {
@NotBlank(message = "일정의 이름이 비어있습니다.")
private String title;
private String content;
}
controller>ScheduleController
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor // final로 선언된 멤버 변수를 파라미터로 사용하여 생성자를 자동으로 생성
public class ScheduleController {
private final ScheduleService scheduleService;
private final JwtUtil jwtUtil;
@PostMapping("/schedule")
public ResponseEntity<Response> createSchedule(@RequestBody ScheduleRequestDto requestDto, HttpServletRequest req) {
// Access Token과 Refresh Token을 각각의 헤더에서 가져옴
String accessToken = jwtUtil.getAccessTokenFromHeader(req);
String refreshToken = jwtUtil.getRefreshTokenFromHeader(req);
String newAccessToken = jwtUtil.checkToken(accessToken, refreshToken);
Claims claims = jwtUtil.getUserInfoFromToken(newAccessToken);
String username = claims.getSubject();
scheduleService.createSchedule(username, requestDto);
// 응답 반환
Response response = new Response(HttpStatus.OK.value(), "일정이 성공적으로 등록되었습니다.");
return ResponseEntity.ok(response);
}
@GetMapping("/schedule/{scheduleId}")
public ScheduleResponseDto getScheduleById(@PathVariable Long scheduleId) {
return scheduleService.getScheduleById(scheduleId);
}
@GetMapping("/schedules")
public List<ScheduleResponseDto> getSchedules() {
return scheduleService.getSchedules();
}
@PutMapping("/schedule/{scheduleId}")
public ResponseEntity<Response> updateSchedule(@PathVariable Long scheduleId, @RequestBody ScheduleRequestDto requestDto, HttpServletRequest req) {
// Access Token과 Refresh Token을 각각의 헤더에서 가져옴
String accessToken = jwtUtil.getAccessTokenFromHeader(req);
String refreshToken = jwtUtil.getRefreshTokenFromHeader(req);
String newAccessToken = jwtUtil.checkToken(accessToken, refreshToken);
Claims claims = jwtUtil.getUserInfoFromToken(newAccessToken);
String username = claims.getSubject();
scheduleService.updateSchedule(username, scheduleId, requestDto);
// 응답 반환
Response response = new Response(HttpStatus.OK.value(), "일정이 성공적으로 수정되었습니다.");
return ResponseEntity.ok(response);
}
@DeleteMapping("/schedule/{scheduleId}")
public ResponseEntity<Response> deleteSchedule(@PathVariable Long scheduleId, HttpServletRequest req) {
// Access Token과 Refresh Token을 각각의 헤더에서 가져옴
String accessToken = jwtUtil.getAccessTokenFromHeader(req);
String refreshToken = jwtUtil.getRefreshTokenFromHeader(req);
String newAccessToken = jwtUtil.checkToken(accessToken, refreshToken);
Claims claims = jwtUtil.getUserInfoFromToken(newAccessToken);
String username = claims.getSubject();
scheduleService.deleteSchedule(username, scheduleId);
// 응답 반환
Response response = new Response(HttpStatus.OK.value(), "일정이 성공적으로 삭제되었습니다.");
return ResponseEntity.ok(response);
}
}
service>ScheduleService
@Service
@RequiredArgsConstructor // final로 선언된 멤버 변수를 파라미터로 사용하여 생성자를 자동으로 생성
public class ScheduleService {
private final ScheduleRepository scheduleRepository;
private final UserRepository userRepository;
public void createSchedule(String username, ScheduleRequestDto requestDto) {
String title = requestDto.getTitle();
String content = requestDto.getContent();
User user = userRepository.findByUsername(username).orElseThrow(
() -> new NotFoundException("등록된 사용자가 없습니다.")
);
Schedule schedule = new Schedule(title, content, user);
scheduleRepository.save(schedule);
}
public ScheduleResponseDto getScheduleById(Long id) {
return scheduleRepository.findById(id)
.map(ScheduleResponseDto::new)
.orElseThrow(() ->
new NotFoundException("선택한 일정은 존재하지 않습니다.")
);
}
public List<ScheduleResponseDto> getSchedules() {
// DB 조회
return scheduleRepository.findAllByOrderByCreatedAtDesc().stream().map(ScheduleResponseDto::new).toList();
}
@Transactional
public void updateSchedule(String username, Long scheduleId, ScheduleRequestDto requestDto) {
// 해당 일정이 DB에 존재하는지 확인
Schedule schedule = findScheduleByIdAndUserId(scheduleId, username);
String title = requestDto.getTitle();
String content = requestDto.getContent();
User user = userRepository.findByUsername(username).orElseThrow(
() -> new NotFoundException("등록된 사용자가 없습니다.")
);
schedule.update(title, content, user);
}
public void deleteSchedule(String username, Long scheduleId) {
// 해당 일정이 DB에 존재하는지 확인
Schedule schedule = findScheduleByIdAndUserId(scheduleId, username);
scheduleRepository.delete(schedule);
}
private Schedule findSchedule(Long id) {
return scheduleRepository.findById(id).orElseThrow(() ->
new NotFoundException("선택한 일정은 존재하지 않습니다.")
);
}
// 일정 ID로 일정 조회 및 사용자 ID로 권한 확인 메소드
private Schedule findScheduleByIdAndUserId(Long scheduleId, String userId) {
Schedule schedule = findSchedule(scheduleId); // 선택한 일정이 DB에 저장되어 있는지 확인
if (!schedule.getUser().getUsername().equals(userId)) {
throw new IllegalArgumentException("작성자만 삭제/수정할 수 있습니다.");
}
return schedule;
}
}
[중복 코드 정리 및 코드 최적화]
controller>ScheduleController
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor // final로 선언된 멤버 변수를 파라미터로 사용하여 생성자를 자동으로 생성
public class ScheduleController {
private final ScheduleService scheduleService;
private final JwtUtil jwtUtil;
@PostMapping("/schedule")
public ResponseEntity<Response> createSchedule(@RequestBody ScheduleRequestDto requestDto, HttpServletRequest req) {
String username = getUsernameFromRequest(req);
scheduleService.createSchedule(username, requestDto);
Response response = new Response(HttpStatus.OK.value(), "일정이 성공적으로 등록되었습니다.");
return ResponseEntity.ok(response);
}
@GetMapping("/schedule/{scheduleId}")
public ScheduleResponseDto getScheduleById(@PathVariable Long scheduleId) {
return scheduleService.getScheduleById(scheduleId);
}
@GetMapping("/schedules")
public List<ScheduleResponseDto> getSchedules() {
return scheduleService.getSchedules();
}
@PutMapping("/schedule/{scheduleId}")
public ResponseEntity<Response> updateSchedule(@PathVariable Long scheduleId, @RequestBody ScheduleRequestDto requestDto, HttpServletRequest req) {
String username = getUsernameFromRequest(req);
scheduleService.updateSchedule(username, scheduleId, requestDto);
Response response = new Response(HttpStatus.OK.value(), "일정이 성공적으로 수정되었습니다.");
return ResponseEntity.ok(response);
}
@DeleteMapping("/schedule/{scheduleId}")
public ResponseEntity<Response> deleteSchedule(@PathVariable Long scheduleId, HttpServletRequest req) {
String username = getUsernameFromRequest(req);
scheduleService.deleteSchedule(username, scheduleId);
Response response = new Response(HttpStatus.OK.value(), "일정이 성공적으로 삭제되었습니다.");
return ResponseEntity.ok(response);
}
private String getUsernameFromRequest(HttpServletRequest req) {
// Access Token과 Refresh Token을 각각의 헤더에서 가져옴
String accessToken = jwtUtil.getAccessTokenFromHeader(req);
String refreshToken = jwtUtil.getRefreshTokenFromHeader(req);
String newAccessToken = jwtUtil.checkToken(accessToken, refreshToken);
Claims claims = jwtUtil.getUserInfoFromToken(newAccessToken);
return claims.getSubject();
}
}
controller>CommentController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class CommentController {
private final CommentService commentService;
private final JwtUtil jwtUtil;
// 댓글 작성
@PostMapping("/comment")
public ResponseEntity<Response> createComment(@Valid @RequestBody CommentRequestDto requestDto, HttpServletRequest req) {
String username = getUsernameFromRequest(req);
commentService.createComment(username, requestDto);
Response response = new Response(HttpStatus.OK.value(), "댓글이 성공적으로 등록되었습니다.");
return ResponseEntity.ok(response);
}
// 댓글 수정
@PutMapping("/comment/{commentId}")
public ResponseEntity<Response> updateComment(@PathVariable Long commentId, @Valid @RequestBody CommentRequestDto requestDto, HttpServletRequest req) {
String username = getUsernameFromRequest(req);
commentService.updateComment(commentId, username, requestDto);
Response response = new Response(HttpStatus.OK.value(), "댓글이 성공적으로 수정되었습니다.");
return ResponseEntity.ok(response);
}
// 댓글 삭제
@DeleteMapping("/comment/{commentId}")
public ResponseEntity<Response> deleteComment(@PathVariable Long commentId, @RequestBody CommentRequestDto requestDto, HttpServletRequest req) {
String username = getUsernameFromRequest(req);
commentService.deleteComment(commentId, username, requestDto);
Response response = new Response(HttpStatus.OK.value(), "댓글이 성공적으로 삭제되었습니다.");
return ResponseEntity.ok(response);
}
private String getUsernameFromRequest(HttpServletRequest req) {
// Access Token과 Refresh Token을 각각의 헤더에서 가져옴
String accessToken = jwtUtil.getAccessTokenFromHeader(req);
String refreshToken = jwtUtil.getRefreshTokenFromHeader(req);
String newAccessToken = jwtUtil.checkToken(accessToken, refreshToken);
Claims claims = jwtUtil.getUserInfoFromToken(newAccessToken);
return claims.getSubject();
}
}
'TIL' 카테고리의 다른 글
[TIL] 230603 <Spring> 회원가입, 로그인 기능이 있는 투두앱 백엔드 서버 만들기 (3) (1) | 2024.06.03 |
---|---|
[TIL] 230531 <Spring> Filter (0) | 2024.05.31 |
[TIL] 230529 <Spring> 회원가입, 로그인 기능이 있는 투두앱 백엔드 서버 만들기 (1) (0) | 2024.05.29 |
[TIL] 230528 <Spring> My Select Shop (0) | 2024.05.28 |
[TIL] 230527 <Spring> 사용자 관리하기, 데이터 검증하기 (0) | 2024.05.27 |
소중한 공감 감사합니다