Skip to content

Commit 86956f8

Browse files
authored
feat: 사용자 가입, 로그인, 탈퇴 등 필수 기능 구현 (#1)
* Java 버전 재설정 * db, jwt, springdoc 관련 설정 추가 * 각종 dependencies 추가 * Enable JpaAuditing * 각종 예외 클래스 추가 * CommonResponse 정의 * User entity 및 repository 정의 * User entity 및 repository 정의 * RefreshToken 관련 entity, repository 정의 * GlobalExceptionHandler 정의 * SecurityConfig 정의 및 로그인 위한 AuthenticationFilter 정의 * CustomUserDetailsService 정의 * Auth 기능 controller 및 service 정의 * User 기능 controller 및 service 정의 * Jwt용 TokenProvider 정의 * Swagger 사용 위해 SwaggerConfig 정의 * feat: user에 isActive 열 추가해 계정 탈퇴 시 행 delete 대신 단순 update로 관리 * fix: 잘못/만료된 refreshtoken 통과되는 오류 해결 * fix: PK를 email에서 uuid로 변경 * style: 오타 수정 * fix: CustomUserDetails 사용하도록 통일 * fix: 클래스 이름 형식 통일 * feat: User간 .equals(User)로 비교 가능하게 수정
1 parent 52a891e commit 86956f8

28 files changed

+869
-1
lines changed

build.gradle

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ version = '0.0.1-SNAPSHOT'
99

1010
java {
1111
toolchain {
12-
languageVersion = JavaLanguageVersion.of(21)
12+
languageVersion = JavaLanguageVersion.of(17)
1313
}
1414
}
1515

@@ -26,7 +26,18 @@ repositories {
2626
dependencies {
2727
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
2828
implementation 'org.springframework.boot:spring-boot-starter-web'
29+
implementation 'org.springframework.boot:spring-boot-starter-validation'
30+
implementation 'org.springframework.boot:spring-boot-starter-actuator'
31+
implementation 'org.springframework.boot:spring-boot-starter-security'
32+
implementation 'org.springframework.security:spring-security-oauth2-jose'
33+
implementation 'org.springframework.security:spring-security-crypto'
34+
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6'
35+
implementation 'org.bouncycastle:bcprov-jdk18on:1.80'
36+
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
37+
2938
compileOnly 'org.projectlombok:lombok'
39+
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
40+
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
3041
runtimeOnly 'com.mysql:mysql-connector-j'
3142
annotationProcessor 'org.projectlombok:lombok'
3243
testImplementation 'org.springframework.boot:spring-boot-starter-test'

src/main/java/apptive/devlog/DevlogApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
56

67
@SpringBootApplication
8+
@EnableJpaAuditing
79
public class DevlogApplication {
810

911
public static void main(String[] args) {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package apptive.devlog;
2+
3+
import io.swagger.v3.oas.models.Components;
4+
import io.swagger.v3.oas.models.OpenAPI;
5+
import io.swagger.v3.oas.models.info.Info;
6+
import io.swagger.v3.oas.models.security.SecurityRequirement;
7+
import io.swagger.v3.oas.models.security.SecurityScheme;
8+
import io.swagger.v3.oas.models.servers.Server;
9+
import org.springframework.context.annotation.Bean;
10+
import org.springframework.context.annotation.Configuration;
11+
12+
import java.util.List;
13+
14+
@Configuration
15+
public class SwaggerConfig {
16+
@Bean
17+
public OpenAPI openAPI() {
18+
String jwt = "JWT";
19+
SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwt);
20+
Components components = new Components().addSecuritySchemes(jwt, new SecurityScheme()
21+
.name(jwt)
22+
.type(SecurityScheme.Type.HTTP)
23+
.scheme("bearer")
24+
.bearerFormat("JWT")
25+
);
26+
Server server = new Server();
27+
server.setUrl("https://devlog.jun0.dev");
28+
29+
return new OpenAPI()
30+
.components(new Components())
31+
.info(apiInfo())
32+
.servers(List.of(server))
33+
.addSecurityItem(securityRequirement)
34+
.components(components);
35+
}
36+
private Info apiInfo() {
37+
return new Info()
38+
.title("Devlog API Documentation")
39+
.version("0.0.1");
40+
}
41+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package apptive.devlog.auth;
2+
3+
import apptive.devlog.auth.dto.*;
4+
import apptive.devlog.common.CommonResponse;
5+
import io.swagger.v3.oas.annotations.Operation;
6+
import io.swagger.v3.oas.annotations.media.Content;
7+
import io.swagger.v3.oas.annotations.media.Schema;
8+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
9+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
10+
import jakarta.validation.Valid;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.http.HttpStatus;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
15+
import org.springframework.security.core.userdetails.UserDetails;
16+
import org.springframework.web.bind.annotation.PostMapping;
17+
import org.springframework.web.bind.annotation.RequestBody;
18+
import org.springframework.web.bind.annotation.RequestMapping;
19+
import org.springframework.web.bind.annotation.RestController;
20+
21+
import java.util.Map;
22+
23+
@RestController
24+
@RequestMapping("/auth")
25+
@RequiredArgsConstructor
26+
public class AuthController {
27+
private final AuthService authService;
28+
29+
@PostMapping("/register")
30+
@Operation(summary = "회원 가입", description = "회원으로 가입합니다.")
31+
@ApiResponses(value = {
32+
@ApiResponse(responseCode = "201", description = "가입 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))})
33+
public ResponseEntity<?> register(@RequestBody @Valid RegisterRequest request) {
34+
authService.register(request);
35+
return CommonResponse.buildResponseEntity(HttpStatus.CREATED, "성공적으로 가입되었습니다.");
36+
}
37+
38+
@PostMapping("/login")
39+
@Operation(summary = "로그인", description = "정보 일치 시 AccessToken과 RefreshToken을 발급합니다.")
40+
@ApiResponses(value = {
41+
@ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))})
42+
public ResponseEntity<?> login(@RequestBody @Valid LoginRequest request) {
43+
LoginResult loginResult = authService.login(request);
44+
return CommonResponse.buildResponseEntity(HttpStatus.OK, "로그인 되었습니다.", loginResult);
45+
}
46+
47+
@PostMapping("/logout")
48+
@Operation(summary = "로그아웃", description = "RefreshToken을 Revoke합니다.")
49+
@ApiResponses(value = {
50+
@ApiResponse(responseCode = "200", description = "로그아웃 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))})
51+
public ResponseEntity<?> logout(@AuthenticationPrincipal UserDetails userDetails) {
52+
authService.logout(userDetails.getUsername());
53+
return CommonResponse.buildResponseEntity(HttpStatus.OK, "로그아웃 되었습니다.");
54+
}
55+
56+
@PostMapping("/refresh")
57+
@Operation(summary = "AccessToken 재발급", description = "AccessToken을 재발급합니다.")
58+
@ApiResponses(value = {
59+
@ApiResponse(responseCode = "200", description = "재발급 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))})
60+
public ResponseEntity<?> refresh(@RequestBody @Valid RefreshRequest request) {
61+
String newAccessToken = authService.refresh(request.refreshToken());
62+
return CommonResponse.buildResponseEntity(HttpStatus.OK, "AccessToken 재발급 되었습니다.",
63+
Map.of("accessToken", newAccessToken));
64+
}
65+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package apptive.devlog.auth;
2+
3+
import apptive.devlog.auth.token.RefreshToken;
4+
import apptive.devlog.auth.token.RefreshTokenRepository;
5+
import apptive.devlog.auth.token.TokenProvider;
6+
import apptive.devlog.exception.EmailAlreadyExistsException;
7+
import apptive.devlog.exception.InvalidPasswordException;
8+
import apptive.devlog.exception.NicknameAlreadyExistsException;
9+
import apptive.devlog.user.User;
10+
import apptive.devlog.user.UserRepository;
11+
import org.springframework.security.authentication.AuthenticationManager;
12+
import org.springframework.security.authentication.BadCredentialsException;
13+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
14+
import org.springframework.security.crypto.password.PasswordEncoder;
15+
import org.springframework.stereotype.Service;
16+
import org.springframework.transaction.annotation.Transactional;
17+
18+
import apptive.devlog.auth.dto.*;
19+
import lombok.RequiredArgsConstructor;
20+
21+
import java.time.LocalDateTime;
22+
import java.util.regex.Pattern;
23+
24+
@Service
25+
@RequiredArgsConstructor
26+
@Transactional
27+
public class AuthService {
28+
private final AuthenticationManager authenticationManager;
29+
private final TokenProvider tokenProvider;
30+
private final UserRepository userRepository;
31+
private final RefreshTokenRepository refreshTokenRepository;
32+
private final PasswordEncoder passwordEncoder;
33+
34+
public void register(RegisterRequest request) {
35+
if (userRepository.existsByEmail(request.email())) {
36+
throw new EmailAlreadyExistsException("이미 사용중인 이메일입니다.");
37+
}
38+
if (userRepository.existsByNickname(request.nickname())) {
39+
throw new NicknameAlreadyExistsException("이미 사용중인 닉네임입니다.");
40+
}
41+
if (!validatePassword(request.password())) {
42+
throw new InvalidPasswordException("비밀번호는 대소문자, 특수문자 포함 10자 이상이어야 합니다.");
43+
}
44+
45+
User user = User.builder()
46+
.email(request.email())
47+
.password(passwordEncoder.encode(request.password()))
48+
.name(request.name())
49+
.nickname(request.nickname())
50+
.birth(request.birth())
51+
.gender(request.gender())
52+
.build();
53+
userRepository.save(user);
54+
}
55+
56+
public LoginResult login(LoginRequest request) {
57+
try {
58+
authenticationManager.authenticate(
59+
new UsernamePasswordAuthenticationToken(request.email(), request.password()));
60+
61+
User user = userRepository.findByEmail(request.email())
62+
.orElseThrow(() -> new BadCredentialsException("사용자 없음"));
63+
64+
String accessTokenString = tokenProvider.generateAccessToken(user.getEmail());
65+
String refreshTokenString = tokenProvider.generateRefreshToken();
66+
RefreshToken refreshToken = RefreshToken.builder()
67+
.token(refreshTokenString)
68+
.userEmail(request.email())
69+
.expiry(LocalDateTime.now().plusDays(TokenProvider.REFRESH_TOKEN_TTL))
70+
.build();
71+
refreshTokenRepository.deleteByUserEmail(request.email());
72+
refreshTokenRepository.save(refreshToken);
73+
74+
return new LoginResult(accessTokenString, refreshTokenString);
75+
} catch (BadCredentialsException exception) {
76+
throw new BadCredentialsException("이메일 또는 비밀번호가 잘못되었습니다.");
77+
}
78+
}
79+
80+
public void logout(String email) {
81+
refreshTokenRepository.deleteByUserEmail(email);
82+
}
83+
84+
public String refresh(String refreshTokenString) {
85+
RefreshToken refreshToken = refreshTokenRepository.findByToken(refreshTokenString);
86+
if(refreshToken == null || refreshToken.getExpiry().isBefore(LocalDateTime.now()))
87+
throw new BadCredentialsException("RefreshToken이 바르지 않습니다.");
88+
89+
return tokenProvider.generateAccessToken(refreshToken.getUserEmail());
90+
}
91+
92+
private boolean validatePassword(String password) {
93+
Pattern pattern = Pattern.compile("^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[^a-zA-Z0-9]).{10,}$");
94+
return pattern.matcher(password).matches();
95+
}
96+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package apptive.devlog.auth.dto;
2+
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 email,
10+
11+
@NotBlank(message = "비밀번호는 필수 입력값입니다.")
12+
String password
13+
) { }
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package apptive.devlog.auth.dto;
2+
3+
public record LoginResult(
4+
String accessToken,
5+
String refreshToken
6+
) {
7+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package apptive.devlog.auth.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
public record RefreshRequest (
6+
@NotBlank String refreshToken
7+
)
8+
{ }
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package apptive.devlog.auth.dto;
2+
3+
import apptive.devlog.user.User;
4+
import jakarta.validation.constraints.Email;
5+
import jakarta.validation.constraints.NotBlank;
6+
import jakarta.validation.constraints.NotNull;
7+
8+
import java.time.LocalDate;
9+
10+
public record RegisterRequest(
11+
@NotBlank(message = "이메일은 필수 입력값입니다.")
12+
@Email(message = "이메일 형식이 아닙니다.")
13+
String email,
14+
15+
@NotBlank(message = "비밀번호는 필수 입력값입니다.")
16+
String password,
17+
18+
@NotBlank(message = "이름은 필수 입력값입니다.")
19+
String name,
20+
21+
@NotBlank(message = "닉네임은 필수 입력값입니다.")
22+
String nickname,
23+
24+
@NotNull(message = "생년월일은 필수 입력값입니다.")
25+
LocalDate birth,
26+
27+
@NotNull(message = "성별은 필수 입력값입니다.")
28+
User.Gender gender
29+
){}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package apptive.devlog.auth.token;
2+
3+
import jakarta.servlet.FilterChain;
4+
import jakarta.servlet.ServletException;
5+
import jakarta.servlet.http.HttpServletRequest;
6+
import jakarta.servlet.http.HttpServletResponse;
7+
import lombok.NonNull;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.security.core.Authentication;
10+
import org.springframework.security.core.context.SecurityContextHolder;
11+
import org.springframework.web.filter.OncePerRequestFilter;
12+
13+
import java.io.IOException;
14+
15+
@RequiredArgsConstructor
16+
public class AuthenticationFilter extends OncePerRequestFilter {
17+
private final TokenProvider tokenProvider;
18+
19+
@Override
20+
protected void doFilterInternal(@NonNull HttpServletRequest request,
21+
@NonNull HttpServletResponse response,
22+
@NonNull FilterChain filterChain) throws ServletException, IOException {
23+
String token = tokenProvider.resolveToken(request);
24+
25+
if (token != null && tokenProvider.validateToken(token)) {
26+
Authentication auth = tokenProvider.getAuthentication(token);
27+
SecurityContextHolder.getContext().setAuthentication(auth);
28+
}
29+
30+
filterChain.doFilter(request, response);
31+
}
32+
}
33+

0 commit comments

Comments
 (0)