Skip to content

Commit

Permalink
[FEAT] 회원가입 기능 구현 (#193)
Browse files Browse the repository at this point in the history
* style: java.net.URI import 위치 변경

* rename: AuthController -> OAuthController 이름 변경

* feat: 회원가입 요청, 응답 Dto 생성

* feat: 멤버 엔티티 비밀번호, 솔트 필드 추가 및 입력받은 회원가입 정보로 회원 생성

* feat: 비밀번호 인코더 예외 코드 작성

* feat: 비밀번호 인코더 구현

* feat: AuthController 및 AuthService 회원가입 기능 구현

* feat: 회원가입 Dto 검증 적용

* style: findOrCreateSignUpMemberByEmail -> createSignUpMember 메서드명 변경

* refactor(MemberSignUpResDto): 응답 필드 변경

* feat: 가입된 이메일이 있는 경우 예외 처리

* refactor: 존재하는 회원 -> 존재하는 메일로 변경

* refactor: 존재하는 -> 사용중인 에러메시지 변경

* refactor(MemberErrorCode): 사용중인 메일입니다 -> 사용중인 이메일입니다 에러 메시지 변경

* fix(MemberService): 회원가입시 암호화 두번 되던 오류 수정

* refactor(PasswordEncoder): 암호화 알고리즘 SHA1에서 SHA256 변경

* refactor(PasswordHashDto): PasswordHashDto 분리

* style(MemberService): newSignUpMember, signUpMember로 메서드명 변경

* refactor(PasswordEncoder): PBKDF2 반복 횟수 65,536에서 10,000으로 최적화
  • Loading branch information
injae-348 authored Nov 16, 2024
1 parent b9d26ac commit 9245692
Show file tree
Hide file tree
Showing 16 changed files with 357 additions and 80 deletions.
88 changes: 13 additions & 75 deletions src/main/java/econo/buddybridge/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -1,99 +1,37 @@
package econo.buddybridge.auth.controller;

import econo.buddybridge.auth.OAuthProvider;
import econo.buddybridge.auth.dto.kakao.KakaoLoginParams;
import econo.buddybridge.auth.exception.AlreadyLogoutException;
import econo.buddybridge.auth.jwt.AuthToken;
import econo.buddybridge.auth.resolver.MemberToken;
import econo.buddybridge.auth.service.OAuthLoginService;
import econo.buddybridge.auth.service.AuthService;
import econo.buddybridge.common.annotation.AllowAnonymous;
import econo.buddybridge.member.dto.MemberResDto;
import econo.buddybridge.member.dto.MemberSignUpReqDto;
import econo.buddybridge.member.dto.MemberSignUpResDto;
import econo.buddybridge.utils.api.ApiResponse;
import econo.buddybridge.utils.api.ApiResponse.CustomBody;
import econo.buddybridge.utils.api.ApiResponseGenerator;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.net.URI;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
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;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/oauth")
@RequestMapping("/api/auth")
@Tag(name = "인증 API", description = "인증 관련 API")
public class AuthController {

private final OAuthLoginService oAuthLoginService;
private final AuthService authService;

@Value("${oauth.kakao.url.front-url}")
private String frontUrl;

@Operation(summary = "로그아웃", description = "세션을 제거합니다.")
@Operation(summary = "회원 가입", description = "회원을 추가합니다.")
@PostMapping("/signup")
@AllowAnonymous
@PostMapping("/logout")
public ApiResponse<CustomBody<String>> logout(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
oAuthLoginService.logout(OAuthProvider.KAKAO);
return ApiResponseGenerator.success("로그아웃 성공", HttpStatus.OK);
}
throw AlreadyLogoutException.EXCEPTION;
}

@Operation(summary = "카카오 소셜 로그인 (코드로 로그인)", description = "Redirect URL이 백엔드 주소로 설정될 때 사용합니다.")
@GetMapping("/login")
public ApiResponse<CustomBody<MemberResDto>> login(@RequestParam("code") String code, HttpServletRequest request) {
KakaoLoginParams params = new KakaoLoginParams(code);

MemberResDto memberDto = oAuthLoginService.login(params);

HttpSession session = request.getSession(true);
session.setAttribute("memberId", memberDto.memberId());

// 프론트엔드 주소로 redirect
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setLocation(URI.create(frontUrl));

return ApiResponseGenerator.success(memberDto, httpHeaders, HttpStatus.PERMANENT_REDIRECT);
}

@Operation(summary = "카카오 소셜 로그인 (토큰으로 로그인)", description = "Redirect URL이 프론트엔드 주소로 설정될 때 사용합니다.")
@PostMapping("/login")
public ApiResponse<CustomBody<MemberResDto>> login(@RequestBody KakaoLoginParams params, HttpServletRequest request) {
MemberResDto memberDto = oAuthLoginService.login(params);

HttpSession session = request.getSession(true);
session.setAttribute("memberId", memberDto.memberId());

return ApiResponseGenerator.success(memberDto, HttpStatus.OK);
}

// 소셜로그인 with JWT
@Operation(summary = "카카오 소셜 로그인 (JWT)", description = "JWT를 이용하여 로그인합니다.")
@PostMapping("/login/jwt")
public ApiResponse<CustomBody<AuthToken>> loginWithToken(@RequestBody KakaoLoginParams params) {
AuthToken authToken = oAuthLoginService.loginWithToken(params);
return ApiResponseGenerator.success(authToken, HttpStatus.OK);
}

// refresh token 재발급
@Operation(summary = "Access Token, Refresh Token 재발급", description = "Refresh Token을 이용하여 두 토큰 모두 재발급합니다.")
@PostMapping("/reissue")
public ApiResponse<CustomBody<AuthToken>> reissue(@MemberToken String token) {
AuthToken authToken = oAuthLoginService.reissue(token);
return ApiResponseGenerator.success(authToken, HttpStatus.OK);
public ApiResponse<CustomBody<MemberSignUpResDto>> signUp(
@Valid @RequestBody MemberSignUpReqDto memberSignUpReqDto
) {
MemberSignUpResDto memberSignUpResDto = authService.signUp(memberSignUpReqDto);
return ApiResponseGenerator.success(memberSignUpResDto, HttpStatus.OK);
}
}
100 changes: 100 additions & 0 deletions src/main/java/econo/buddybridge/auth/controller/OAuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package econo.buddybridge.auth.controller;

import econo.buddybridge.auth.OAuthProvider;
import econo.buddybridge.auth.dto.kakao.KakaoLoginParams;
import econo.buddybridge.auth.exception.AlreadyLogoutException;
import econo.buddybridge.auth.jwt.AuthToken;
import econo.buddybridge.auth.resolver.MemberToken;
import econo.buddybridge.auth.service.OAuthLoginService;
import econo.buddybridge.common.annotation.AllowAnonymous;
import econo.buddybridge.member.dto.MemberResDto;
import econo.buddybridge.utils.api.ApiResponse;
import econo.buddybridge.utils.api.ApiResponse.CustomBody;
import econo.buddybridge.utils.api.ApiResponseGenerator;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
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;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.net.URI;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/oauth")
@Tag(name = "인증 API", description = "인증 관련 API")
public class OAuthController {

private final OAuthLoginService oAuthLoginService;

@Value("${oauth.kakao.url.front-url}")
private String frontUrl;

@Operation(summary = "로그아웃", description = "세션을 제거합니다.")
@AllowAnonymous
@PostMapping("/logout")
public ApiResponse<CustomBody<String>> logout(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
oAuthLoginService.logout(OAuthProvider.KAKAO);
return ApiResponseGenerator.success("로그아웃 성공", HttpStatus.OK);
}
throw AlreadyLogoutException.EXCEPTION;
}

@Operation(summary = "카카오 소셜 로그인 (코드로 로그인)", description = "Redirect URL이 백엔드 주소로 설정될 때 사용합니다.")
@GetMapping("/login")
public ApiResponse<CustomBody<MemberResDto>> login(@RequestParam("code") String code, HttpServletRequest request) {
KakaoLoginParams params = new KakaoLoginParams(code);

MemberResDto memberDto = oAuthLoginService.login(params);

HttpSession session = request.getSession(true);
session.setAttribute("memberId", memberDto.memberId());

// 프론트엔드 주소로 redirect
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setLocation(URI.create(frontUrl));

return ApiResponseGenerator.success(memberDto, httpHeaders, HttpStatus.PERMANENT_REDIRECT);
}

@Operation(summary = "카카오 소셜 로그인 (토큰으로 로그인)", description = "Redirect URL이 프론트엔드 주소로 설정될 때 사용합니다.")
@PostMapping("/login")
public ApiResponse<CustomBody<MemberResDto>> login(@RequestBody KakaoLoginParams params, HttpServletRequest request) {
MemberResDto memberDto = oAuthLoginService.login(params);

HttpSession session = request.getSession(true);
session.setAttribute("memberId", memberDto.memberId());

return ApiResponseGenerator.success(memberDto, HttpStatus.OK);
}

// 소셜로그인 with JWT
@Operation(summary = "카카오 소셜 로그인 (JWT)", description = "JWT를 이용하여 로그인합니다.")
@PostMapping("/login/jwt")
public ApiResponse<CustomBody<AuthToken>> loginWithToken(@RequestBody KakaoLoginParams params) {
AuthToken authToken = oAuthLoginService.loginWithToken(params);
return ApiResponseGenerator.success(authToken, HttpStatus.OK);
}

// refresh token 재발급
@Operation(summary = "Access Token, Refresh Token 재발급", description = "Refresh Token을 이용하여 두 토큰 모두 재발급합니다.")
@PostMapping("/reissue")
public ApiResponse<CustomBody<AuthToken>> reissue(@MemberToken String token) {
AuthToken authToken = oAuthLoginService.reissue(token);
return ApiResponseGenerator.success(authToken, HttpStatus.OK);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
@RequiredArgsConstructor
@RequestMapping("/api/oauth")
@Tag(name = "인증 API")
public class AuthTestController {
public class OAuthTestController {

private final MemberService memberService;
private final AuthTokenService authTokenService;
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/econo/buddybridge/auth/dto/PasswordHashDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package econo.buddybridge.auth.dto;

public record PasswordHashDto(
String hashedPassword,
String salt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package econo.buddybridge.auth.exception;

import econo.buddybridge.common.exception.ErrorCode;
import org.springframework.http.HttpStatus;

public enum EncoderErrorCode implements ErrorCode {
ENCRYPT_FAILED("EN001", HttpStatus.INTERNAL_SERVER_ERROR, "서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요."),
GENERATE_SALT_FAILED("EN002", HttpStatus.INTERNAL_SERVER_ERROR, "서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요."),
;

private final String code;
private final HttpStatus httpStatus;
private final String message;

EncoderErrorCode(String code, HttpStatus httpStatus, String message) {
this.code = code;
this.httpStatus = httpStatus;
this.message = message;
}

@Override
public String getCode() {
return code;
}

@Override
public HttpStatus getHttpStatus() {
return httpStatus;
}

@Override
public String getMessage() {
return message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package econo.buddybridge.auth.exception;

import econo.buddybridge.common.exception.BusinessException;

public class EncryptFailedException extends BusinessException {

public static BusinessException EXCEPTION = new EncryptFailedException();

private EncryptFailedException() {
super(EncoderErrorCode.ENCRYPT_FAILED);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package econo.buddybridge.auth.exception;

import econo.buddybridge.common.exception.BusinessException;

public class GenerateSaltFailedException extends BusinessException {

public static BusinessException EXCEPTION = new GenerateSaltFailedException();

private GenerateSaltFailedException() {
super(EncoderErrorCode.GENERATE_SALT_FAILED);
}
}
18 changes: 18 additions & 0 deletions src/main/java/econo/buddybridge/auth/service/AuthService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package econo.buddybridge.auth.service;

import econo.buddybridge.member.dto.MemberSignUpReqDto;
import econo.buddybridge.member.dto.MemberSignUpResDto;
import econo.buddybridge.member.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class AuthService {

private final MemberService memberService;

public MemberSignUpResDto signUp(MemberSignUpReqDto memberSignUpReqDto) {
return memberService.createSignUpMember(memberSignUpReqDto);
}
}
52 changes: 52 additions & 0 deletions src/main/java/econo/buddybridge/auth/utils/PasswordEncoder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package econo.buddybridge.auth.utils;

import econo.buddybridge.auth.dto.PasswordHashDto;
import econo.buddybridge.auth.exception.EncryptFailedException;
import econo.buddybridge.auth.exception.GenerateSaltFailedException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;
import java.util.Base64;

@Slf4j
@Component
public class PasswordEncoder {

public PasswordHashDto encrypt(String password) {
byte[] salt = generateRandomSalt();
String hashedPassword = hashPassword(password, salt);
String saltString = Base64.getEncoder().encodeToString(salt);

return new PasswordHashDto(hashedPassword, saltString);
}

private String hashPassword(String password, byte[] salt) {
try {
KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, 10000, 128);
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] hash = factory.generateSecret(spec).getEncoded();
return Base64.getEncoder().encodeToString(hash);
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
log.error("비밀번호 암호화에 실패했습니다.", e); // 서버 에러는 로그로 처리
throw EncryptFailedException.EXCEPTION;
}
}

private byte[] generateRandomSalt() {
try {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[16];
random.nextBytes(salt);
return salt;
} catch (Exception e) {
log.error("솔트 생성에 실패했습니다.", e); // 서버 에러는 로그로 처리
throw GenerateSaltFailedException.EXCEPTION;
}
}
}
28 changes: 28 additions & 0 deletions src/main/java/econo/buddybridge/member/dto/MemberSignUpReqDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package econo.buddybridge.member.dto;

import econo.buddybridge.member.entity.Gender;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

import java.time.LocalDate;

public record MemberSignUpReqDto(
@NotBlank(message = "이름을 입력해주세요.")
String name,

@NotNull(message = "성별을 선택해주세요.")
Gender gender,

@NotNull(message = "생년월일을 입력해주세요.")
LocalDate birthDate,

@NotBlank(message = "이메일을 입력해주세요.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email,

@NotBlank(message = "비밀번호를 입력해주세요.")
String password
) {

}
Loading

0 comments on commit 9245692

Please sign in to comment.