Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import com.project.InsightPrep.domain.auth.controller.docs.AuthControllerDocs;
import com.project.InsightPrep.domain.auth.dto.request.AuthRequest;
import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.ResetTokenRes;
import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto;
import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.MeDto;
import com.project.InsightPrep.domain.auth.exception.AuthErrorCode;
import com.project.InsightPrep.domain.auth.exception.AuthException;
import com.project.InsightPrep.domain.auth.service.AuthService;
import com.project.InsightPrep.domain.auth.service.EmailService;
import com.project.InsightPrep.domain.auth.service.PasswordResetService;
import com.project.InsightPrep.global.common.response.ApiResponse;
import com.project.InsightPrep.global.common.response.code.ApiSuccessCode;
import jakarta.servlet.http.Cookie;
Expand All @@ -17,6 +20,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -29,6 +33,7 @@ public class AuthController implements AuthControllerDocs {

private final AuthService authService;
private final EmailService emailService;
private final PasswordResetService passwordResetService;

@PostMapping("/signup")
public ResponseEntity<ApiResponse<?>> signup(@RequestBody @Valid AuthRequest.signupDto request) {
Expand Down Expand Up @@ -79,4 +84,32 @@ public ResponseEntity<ApiResponse<?>> logout (HttpServletRequest request, HttpSe

return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.LOGOUT_SUCCESS));
}

@PreAuthorize("hasAnyRole('USER', 'ADMIN')")
@GetMapping("/me")
public ResponseEntity<ApiResponse<MeDto>> getSessionInfo(HttpSession session) {
MeDto dto = authService.getSessionInfo(session);
return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.ME_SUCCESS, dto));
}

@Override
@PostMapping("/otp/sendEmail")
public ResponseEntity<ApiResponse<Void>> requestOtp(@RequestBody @Valid AuthRequest.MemberEmailDto req) {
passwordResetService.requestOtp(req.getEmail());
return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.SEND_EMAIL_SUCCESS)); // 존재 여부 노출 금지
}

@Override
@PostMapping("/otp/verify")
public ResponseEntity<ApiResponse<ResetTokenRes>> verifyOtp(@RequestBody @Valid AuthRequest.VerifyOtpReq req) {
String token = passwordResetService.verifyOtp(req.email(), req.code());
return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.VERIFIED_EMAIL_SUCCESS, new ResetTokenRes(token)));
}

@Override
@PostMapping("/password/reset")
public ResponseEntity<ApiResponse<Void>> reset(@RequestBody @Valid AuthRequest.ResetReq req) {
passwordResetService.resetPassword(req.resetToken(), req.newPassword());
return ResponseEntity.ok(ApiResponse.of(ApiSuccessCode.SUCCESS));
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.project.InsightPrep.domain.auth.controller.docs;

import com.project.InsightPrep.domain.auth.dto.request.AuthRequest;
import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.ResetTokenRes;
import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto;
import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.MeDto;
import com.project.InsightPrep.global.common.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand Down Expand Up @@ -29,4 +31,16 @@ public interface AuthControllerDocs {

@Operation(summary = "로그아웃", description = "로그아웃을 진행하면 쿠키가 삭제됩니다.")
public ResponseEntity<ApiResponse<?>> logout (HttpServletRequest request, HttpServletResponse response);

@Operation(summary = "세션 조회", description = "로그인 한 사용자가 현재 나의 세션이 유효한지, 그리고 로그인한 사용자가 누구인지 조회합니다.")
public ResponseEntity<ApiResponse<MeDto>> getSessionInfo(HttpSession session);

@Operation(summary = "비밀번호 재설정 인증번호 전송", description = "비밀번호 재전송을 하기 위해 인증번호를 해당 메일로 전송합니다.")
public ResponseEntity<ApiResponse<Void>> requestOtp(@RequestBody @Valid AuthRequest.MemberEmailDto req);

@Operation(summary = "비밀번호 재설정 인증번호 인증", description = "비밀번호 재설정을 위한 인증번호를 인증합니다.")
public ResponseEntity<ApiResponse<ResetTokenRes>> verifyOtp(@RequestBody @Valid AuthRequest.VerifyOtpReq req);

@Operation(summary = "비밀번호 재설정", description = "비밀번호를 재설정합니다.")
public ResponseEntity<ApiResponse<Void>> reset(@RequestBody @Valid AuthRequest.ResetReq req);
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public static class signupDto {

@Getter
@NoArgsConstructor
@Setter
public static class MemberEmailDto {
@NotBlank
@Email
Expand Down Expand Up @@ -70,4 +71,16 @@ public static class LoginDto {

private boolean autoLogin;
}

public record VerifyOtpReq(
@NotBlank @Email String email,
@NotBlank String code
) {}

public static record ResetReq(
@NotBlank String resetToken,
@NotBlank(message = "비밀번호는 필수입니다.") @Pattern(regexp = "^(?=.*[A-Z])(?=.*[@$!%*?&]).{8,16}$", message = "비밀번호는 영문 소문자, 대문자, 특수 문자로 구성되어야 합니다.") String newPassword
) {}

public static record ResetTokenRes(String resetToken) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,12 @@ public static class LoginResultDto {
private Long memberId;
private String nickname;
}

@Getter
@Builder
@JsonInclude(Include.NON_NULL)
public static class MeDto {
private Long memberId;
private String nickname;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.project.InsightPrep.domain.auth.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "password_verification")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class PasswordVerification {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, unique = true, length = 254)
private String email;

@Column(name = "code_hash", nullable = false, length = 255)
private String codeHash;

// 남은 시도 횟수 (예: 기본 5회)
@Column(nullable = false)
private int attemptsLeft;

// 1회용 처리 플래그
@Column(nullable = false)
private boolean used;

// 만료 시각
@Column(nullable = false)
private LocalDateTime expiresAt;

// 감사/운영용
@Column(nullable = false)
private LocalDateTime createdAt;

private LocalDateTime usedAt;

// 비밀번호 재설정 토큰(OTP 성공 후 발급)
@Column(name = "reset_token", length = 255)
private String resetToken;

@Column(name = "reset_expires_at")
private LocalDateTime resetExpiresAt;

@Column(name = "reset_used", nullable = false)
private boolean resetUsed;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,22 @@ public enum AuthErrorCode implements BaseErrorCode {
// 이메일
EMAIL_DUPLICATE_ERROR("EMAIL_DUPLICATE_ERROR", HttpStatus.CONFLICT, "이미 존재하는 이메일입니다."),
EMAIL_VERIFICATION_ERROR("EMAIL_VERIFICATION_ERROR", HttpStatus.BAD_REQUEST, "인증된 이메일이 아닙니다."),
EXPIRED_CODE_ERROR("LINK_EXPIRED_ERROR", HttpStatus.BAD_REQUEST, "코드 입력 시간이 만료되었습니다."),
EXPIRED_CODE_ERROR("EXPIRED_CODE_ERROR", HttpStatus.BAD_REQUEST, "코드 입력 시간이 만료되었습니다."),
ALREADY_SEND_CODE_ERROR("ALREADY_SEND_CODE_ERROR", HttpStatus.BAD_REQUEST, "이미 유효한 인증 코드가 발급되었습니다."),
OTP_ALREADY_USED("OTP_ALREADY_USED", HttpStatus.BAD_REQUEST, "이미 사용한 인증 코드입니다."),
CODE_NOT_MATCH_ERROR("CODE_NOT_MATCH_ERROR", HttpStatus.BAD_REQUEST, "인증 코드가 일치하지 않습니다"),
UNAUTHORIZED("UNAUTHORIZED", HttpStatus.UNAUTHORIZED, "비로그인 상태입니다."),
MEMBER_NOT_FOUND("MEMBER_NOT_FOUND", HttpStatus.BAD_REQUEST, "회원을 찾을 수 없습니다."),
NOT_AUTHENTICATED("NOT_AUTHENTICATED", HttpStatus.UNAUTHORIZED, "로그인 정보가 없습니다."),
INVALID_AUTHENTICATION_PRINCIPAL("INVALID_AUTHENTICATION_PRINCIPAL", HttpStatus.FORBIDDEN, "인증 정보가 올바르지 않습니다.");
INVALID_AUTHENTICATION_PRINCIPAL("INVALID_AUTHENTICATION_PRINCIPAL", HttpStatus.FORBIDDEN, "인증 정보가 올바르지 않습니다."),
NEED_LOGIN_ERROR("NEED_LOGIN_ERROR", HttpStatus.BAD_REQUEST, "로그인이 필요합니다."),
OTP_INVALID("OTP_INVALID", HttpStatus.FORBIDDEN, "실패가 누적되어 인증 번호가 만료되었습니다."),
OTP_INVALID_ATTEMPT("OTP_INVALID_ATTEMPT", HttpStatus.BAD_REQUEST, "유효하지 않은 시도입니다."),

RESET_TOKEN_INVALID("RESET_TOKEN_INVALID", HttpStatus.BAD_REQUEST, "비밀번호 재설정 토큰이 유효하지 않습니다."),
RESET_TOKEN_ALREADY_USED("RESET_TOKEN_ALREADY_USED", HttpStatus.BAD_REQUEST, "이미 사용된 재설정 토큰입니다."),
RESET_TOKEN_EXPIRED("RESET_TOKEN_EXPIRED", HttpStatus.BAD_REQUEST, "재설정 토큰이 만료되었습니다."),
SERVER_ERROR("SERVER_ERROR", HttpStatus.BAD_REQUEST, "서버 에러");


private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ public interface AuthMapper {
Optional<Member> findByEmail(String email);

Optional<Member> findById(@Param("id") Long id);

int updatePasswordByEmail(@Param("email") String email,
@Param("passwordHash") String passwordHash);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.project.InsightPrep.domain.auth.mapper;

import com.project.InsightPrep.domain.auth.entity.PasswordVerification;
import java.time.LocalDateTime;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.data.repository.query.Param;

@Mapper
public interface PasswordMapper {

void upsertPasswordOtp(@Param("email") String email,
@Param("codeHash") String codeHash,
@Param("attemptsLeft") int attemptsLeft,
@Param("used") boolean used,
@Param("expiresAt") LocalDateTime expiresAt,
@Param("createdAt") LocalDateTime createdAt);

PasswordVerification findByEmail(@Param("email") String email);

int updateAttempts(@Param("email") String email,
@Param("attemptsLeft") int attemptsLeft);

int updateOtpAsUsed(@Param("email") String email);

int updateResetToken(@Param("email") String email,
@Param("resetToken") String resetToken,
@Param("resetUsed") boolean resetUsed,
@Param("resetExpiresAt") LocalDateTime resetExpiresAt);

PasswordVerification findByResetToken(@Param("resetToken") String resetToken);

int markResetTokenUsed(@Param("resetToken") String resetToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.LoginDto;
import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.signupDto;
import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto;
import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.MeDto;
import jakarta.servlet.http.HttpSession;

public interface AuthService {
void signup(signupDto request);

LoginResultDto login(LoginDto request);

MeDto getSessionInfo(HttpSession session);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.LoginDto;
import com.project.InsightPrep.domain.auth.dto.request.AuthRequest.signupDto;
import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.LoginResultDto;
import com.project.InsightPrep.domain.auth.dto.response.AuthResponse.MeDto;
import com.project.InsightPrep.domain.auth.exception.AuthErrorCode;
import com.project.InsightPrep.domain.auth.exception.AuthException;
import com.project.InsightPrep.domain.auth.mapper.AuthMapper;
import com.project.InsightPrep.domain.member.entity.Member;
import com.project.InsightPrep.domain.member.entity.Role;
import com.project.InsightPrep.global.auth.domain.CustomUserDetails;
import com.project.InsightPrep.global.auth.util.SecurityUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
Expand All @@ -31,6 +33,7 @@ public class AuthServiceImpl implements AuthService {
private final PasswordEncoder passwordEncoder;
private final AuthMapper authMapper;
private final EmailService emailService;
private final SecurityUtil securityUtil;

@Override
@Transactional
Expand Down Expand Up @@ -83,6 +86,19 @@ public LoginResultDto login(LoginDto dto) {
return new LoginResultDto(member.getId(), member.getNickname());
}

@Override
public MeDto getSessionInfo(HttpSession session) {
if (session == null) {
throw new AuthException(AuthErrorCode.NEED_LOGIN_ERROR);
}

Member member = securityUtil.getAuthenticatedMember();
return MeDto.builder()
.memberId(member.getId())
.nickname(member.getNickname())
.build();
}

private void validateSignUp(signupDto dto) {
emailService.existEmail(dto.getEmail());
emailService.validateEmailVerified(dto.getEmail()); // 이메일 인증 여부
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.project.InsightPrep.domain.auth.service;

public interface PasswordResetService {
void requestOtp(String email);

String verifyOtp(String email, String code);

void resetPassword(String resetToken, String newRawPassword);
}
Loading