새소식

TIL

[TIL] 230530 <Spring> 회원가입, 로그인 기능이 있는 투두앱 백엔드 서버 만들기 (2)

  • -

 

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);
}

 

 


기능

  • 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("토큰이 유효하지 않습니다.");
		}
	}
}

 

 


기능

  • 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();
	}
}
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.