Skip to content

Commit 64cbc2c

Browse files
committed
✨Feat: jwt기반 사용자 회원가입/로그인 기능 구현
1 parent 6497e03 commit 64cbc2c

File tree

18 files changed

+431
-45
lines changed

18 files changed

+431
-45
lines changed

build.gradle

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ repositories {
2121
dependencies {
2222
// web
2323
implementation 'org.springframework.boot:spring-boot-starter-web'
24-
implementation 'org.springframework.boot:spring-boot-starter-security'
2524
implementation 'org.springframework.boot:spring-boot-starter-validation'
2625

26+
// security
27+
implementation 'org.springframework.boot:spring-boot-starter-security'
28+
2729
// Lombok
2830
compileOnly 'org.projectlombok:lombok'
2931
annotationProcessor 'org.projectlombok:lombok'
@@ -41,6 +43,11 @@ dependencies {
4143
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
4244
runtimeOnly 'org.postgresql:postgresql'
4345

46+
// JWT
47+
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
48+
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
49+
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
50+
4451
// AWS S3
4552
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
4653
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.be.sportizebe.domain.auth.controller;
2+
3+
import com.be.sportizebe.domain.auth.dto.request.LoginRequest;
4+
import com.be.sportizebe.domain.auth.dto.response.LoginResponse;
5+
import com.be.sportizebe.domain.auth.service.AuthService;
6+
import com.be.sportizebe.global.response.BaseResponse;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import jakarta.validation.Valid;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.web.bind.annotation.PostMapping;
13+
import org.springframework.web.bind.annotation.RequestBody;
14+
import org.springframework.web.bind.annotation.RequestMapping;
15+
import org.springframework.web.bind.annotation.RestController;
16+
17+
@RestController
18+
@RequiredArgsConstructor
19+
@RequestMapping("/api/auth")
20+
@Tag(name = "auth", description = "인증 관련 API")
21+
public class AuthController {
22+
23+
private final AuthService authService;
24+
25+
@PostMapping("/login")
26+
@Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인")
27+
public ResponseEntity<BaseResponse<LoginResponse>> login(@RequestBody @Valid LoginRequest request) {
28+
LoginResponse response = authService.login(request);
29+
return ResponseEntity.ok(BaseResponse.success("로그인 성공", response));
30+
}
31+
}
Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
package com.be.sportizebe.domain.auth.dto.request;
22

3-
public class LoginRequest {
4-
}
3+
import jakarta.validation.constraints.Email;
4+
import jakarta.validation.constraints.NotBlank;
5+
6+
public record LoginRequest(
7+
@NotBlank(message = "아이디를 입력해주세요.")
8+
@Email(message = "아이디는 이메일 형식만 지원합니다.")
9+
String username,
10+
11+
@NotBlank(message = "비밀번호를 입력해주세요.")
12+
String password
13+
) {
14+
}
Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,25 @@
11
package com.be.sportizebe.domain.auth.dto.response;
22

3-
public class LoginResponse {
4-
}
3+
import com.be.sportizebe.domain.user.entity.Role;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
6+
public record LoginResponse(
7+
@Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
8+
String accessToken,
9+
10+
@Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
11+
String refreshToken,
12+
13+
@Schema(description = "사용자 식별자", example = "1")
14+
Long userId,
15+
16+
@Schema(description = "사용자 아이디(이메일 형식)", example = "imjuyongp@gmail.com")
17+
String username,
18+
19+
@Schema(description = "사용자 권한", example = "USER")
20+
Role role
21+
) {
22+
public static LoginResponse of(String accessToken, String refreshToken, Long userId, String username, Role role) {
23+
return new LoginResponse(accessToken, refreshToken, userId, username, role);
24+
}
25+
}
Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,32 @@
11
package com.be.sportizebe.domain.auth.exception;
22

3-
public enum AuthErrorCode {
3+
import com.be.sportizebe.global.exception.model.BaseErrorCode;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Getter;
6+
import org.springframework.http.HttpStatus;
7+
8+
@Getter
9+
@AllArgsConstructor
10+
public enum AuthErrorCode implements BaseErrorCode {
11+
LOGIN_FAIL("AUTH_4001", "로그인 처리 중 오류 발생", HttpStatus.BAD_REQUEST),
12+
TOKEN_FAIL("AUTH_4002", "액세스 토큰 요청 실패", HttpStatus.UNAUTHORIZED),
13+
USER_INFO_FAIL("AUTH_4003", "사용자 정보 요청 실패", HttpStatus.UNAUTHORIZED),
14+
INVALID_ACCESS_TOKEN("AUTH_4004", "유효하지 않은 액세스 토큰입니다.", HttpStatus.UNAUTHORIZED),
15+
ACCESS_TOKEN_EXPIRED("AUTH_4005", "액세스 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED),
16+
REFRESH_TOKEN_REQUIRED("AUTH_4006", "리프레시 토큰이 필요합니다.", HttpStatus.FORBIDDEN),
17+
18+
JWT_TOKEN_EXPIRED("JWT_4001", "JWT 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED),
19+
UNSUPPORTED_TOKEN("JWT_4002", "지원되지 않는 JWT 형식입니다.", HttpStatus.UNAUTHORIZED),
20+
MALFORMED_JWT_TOKEN("JWT_4003", "JWT 형식이 올바르지 않습니다.", HttpStatus.UNAUTHORIZED),
21+
INVALID_SIGNATURE("JWT_4004", "JWT 서명이 유효하지 않습니다.", HttpStatus.UNAUTHORIZED),
22+
ILLEGAL_ARGUMENT("JWT_4005", "JWT 토큰 값이 잘못되었습니다.", HttpStatus.UNAUTHORIZED),
23+
24+
INVALID_AUTH_CONTEXT("AUTH_4007", "SecurityContext에 인증 정보가 없습니다.", HttpStatus.UNAUTHORIZED),
25+
AUTHENTICATION_NOT_FOUND("AUTH_4008", "로그인이 필요합니다.", HttpStatus.UNAUTHORIZED),
26+
INVALID_PASSWORD("AUTH_4009", "비밀번호가 일치하지 않습니다.", HttpStatus.UNAUTHORIZED),
27+
;
28+
29+
private final String code;
30+
private final String message;
31+
private final HttpStatus status;
432
}
Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,44 @@
11
package com.be.sportizebe.domain.auth.service;
22

3+
import com.be.sportizebe.domain.auth.dto.request.LoginRequest;
4+
import com.be.sportizebe.domain.auth.dto.response.LoginResponse;
5+
import com.be.sportizebe.domain.auth.exception.AuthErrorCode;
6+
import com.be.sportizebe.domain.user.entity.User;
7+
import com.be.sportizebe.domain.user.exception.UserErrorCode;
8+
import com.be.sportizebe.domain.user.repository.UserRepository;
9+
import com.be.sportizebe.global.exception.CustomException;
10+
import com.be.sportizebe.global.jwt.JwtProvider;
11+
import lombok.RequiredArgsConstructor;
12+
import lombok.extern.slf4j.Slf4j;
13+
import org.springframework.security.crypto.password.PasswordEncoder;
14+
import org.springframework.stereotype.Service;
15+
import org.springframework.transaction.annotation.Transactional;
16+
17+
@Service
18+
@Slf4j
19+
@RequiredArgsConstructor
320
public class AuthService {
4-
}
21+
22+
private final JwtProvider jwtProvider;
23+
private final UserRepository userRepository;
24+
private final PasswordEncoder passwordEncoder;
25+
26+
@Transactional
27+
public LoginResponse login(LoginRequest request) {
28+
User user = userRepository.findByUsername(request.username())
29+
.orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND));
30+
31+
if (!passwordEncoder.matches(request.password(), user.getPassword())) {
32+
throw new CustomException(AuthErrorCode.INVALID_PASSWORD);
33+
}
34+
35+
String accessToken = jwtProvider.createAccessToken(user.getId());
36+
String refreshToken = jwtProvider.createRefreshToken(user.getId());
37+
38+
user.updateRefreshToken(refreshToken);
39+
40+
log.info("로그인 성공: {}", user.getUsername());
41+
42+
return LoginResponse.of(accessToken, refreshToken, user.getId(), user.getUsername(), user.getRole());
43+
}
44+
}
Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,33 @@
11
package com.be.sportizebe.domain.user.controller;
22

3+
import com.be.sportizebe.domain.user.dto.request.SignUpRequest;
4+
import com.be.sportizebe.domain.user.dto.response.SignUpResponse;
5+
import com.be.sportizebe.domain.user.service.UserService;
6+
import com.be.sportizebe.global.response.BaseResponse;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import jakarta.validation.Valid;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.http.HttpStatus;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.web.bind.annotation.PostMapping;
14+
import org.springframework.web.bind.annotation.RequestBody;
15+
import org.springframework.web.bind.annotation.RequestMapping;
16+
import org.springframework.web.bind.annotation.RestController;
17+
18+
@RestController
19+
@RequiredArgsConstructor
20+
@RequestMapping("/api/users")
21+
@Tag(name = "user", description = "사용자 관련 API")
322
public class UserController {
4-
}
23+
24+
private final UserService userService;
25+
26+
@PostMapping("/signup")
27+
@Operation(summary = "회원가입", description = "이메일과 비밀번호로 회원가입")
28+
public ResponseEntity<BaseResponse<SignUpResponse>> signUp(@RequestBody @Valid SignUpRequest request) {
29+
SignUpResponse response = userService.signUp(request);
30+
return ResponseEntity.status(HttpStatus.CREATED)
31+
.body(BaseResponse.success("회원가입 성공", response));
32+
}
33+
}
Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
11
package com.be.sportizebe.domain.user.dto.request;
22

3-
public class SignUpRequest {
4-
}
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.Email;
5+
import jakarta.validation.constraints.NotBlank;
6+
import jakarta.validation.constraints.Size;
7+
8+
public record SignUpRequest(
9+
@Schema(description = "사용자 아이디(이메일 형식)", example = "user@example.com")
10+
@NotBlank(message = "아이디를 입력해주세요.")
11+
@Email(message = "아이디는 이메일 형식만 지원합니다.")
12+
String username,
13+
14+
@Schema(description = "비밀번호", example = "password123")
15+
@NotBlank(message = "비밀번호를 입력해주세요.")
16+
// @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.")
17+
String password
18+
) {
19+
}
Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
11
package com.be.sportizebe.domain.user.dto.response;
22

3-
public class SignUpResponse {
4-
}
3+
import com.be.sportizebe.domain.user.entity.Role;
4+
import com.be.sportizebe.domain.user.entity.User;
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
7+
public record SignUpResponse(
8+
@Schema(description = "사용자 식별자", example = "1")
9+
Long userId,
10+
11+
@Schema(description = "사용자 아이디(이메일 형식)", example = "user@example.com")
12+
String username,
13+
14+
@Schema(description = "사용자 권한", example = "USER")
15+
Role role
16+
) {
17+
public static SignUpResponse from(User user) {
18+
return new SignUpResponse(user.getId(), user.getUsername(), user.getRole());
19+
}
20+
}

src/main/java/com/be/sportizebe/domain/user/entity/User.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ public class User extends BaseTimeEntity {
3131
@JsonIgnore // 응답 시 데이터를 json 형식으로 보낼때 이 부분은 보내지 않는다
3232
private String password;
3333

34-
@Column(nullable = false)
3534
@JsonIgnore
3635
private String refreshToken;
3736

@@ -45,4 +44,8 @@ public class User extends BaseTimeEntity {
4544
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
4645
@Builder.Default
4746
private List<Post> posts = new ArrayList<>(); // 작성한 게시글 목록
47+
48+
public void updateRefreshToken(String refreshToken) {
49+
this.refreshToken = refreshToken;
50+
}
4851
}

0 commit comments

Comments
 (0)