Skip to content
Closed
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
27 changes: 27 additions & 0 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Java CI with Gradle (build only/skip tests)

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
build:

runs-on: ubuntu-latest
permissions:
contents: read

steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0

- name: Build with Gradle Wrapper
run: ./gradlew build -x test
14 changes: 13 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ version = '0.0.1-SNAPSHOT'

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
languageVersion = JavaLanguageVersion.of(17)
}
}

Expand All @@ -26,7 +26,19 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.security:spring-security-oauth2-jose'
implementation 'org.springframework.security:spring-security-crypto'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6'
implementation 'org.bouncycastle:bcprov-jdk18on:1.80'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'

compileOnly 'org.projectlombok:lombok'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/apptive/devlog/DevlogApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableJpaAuditing
public class DevlogApplication {

public static void main(String[] args) {
Expand Down
41 changes: 41 additions & 0 deletions src/main/java/apptive/devlog/SwaggerConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package apptive.devlog;

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;

@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
String jwt = "JWT";
SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwt);
Components components = new Components().addSecuritySchemes(jwt, new SecurityScheme()
.name(jwt)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
);
Server server = new Server();
server.setUrl("https://devlog.jun0.dev");

return new OpenAPI()
.components(new Components())
.info(apiInfo())
.servers(List.of(server))
.addSecurityItem(securityRequirement)
.components(components);
}
private Info apiInfo() {
return new Info()
.title("Devlog API Documentation")
.version("0.0.1");
}
}
23 changes: 23 additions & 0 deletions src/main/java/apptive/devlog/async/AsyncConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package apptive.devlog.async;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("mailExecutor")
public Executor mailExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(4);
exec.setMaxPoolSize(8);
exec.setQueueCapacity(100);
exec.setThreadNamePrefix("mail-");
exec.initialize();
return exec;
}
}
65 changes: 65 additions & 0 deletions src/main/java/apptive/devlog/auth/AuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package apptive.devlog.auth;

import apptive.devlog.auth.dto.*;
import apptive.devlog.common.CommonResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
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.RestController;

import java.util.Map;

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;

@PostMapping("/register")
@Operation(summary = "회원 가입", description = "회원으로 가입합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "가입 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))})
public ResponseEntity<?> register(@RequestBody @Valid RegisterRequest request) {
authService.register(request);
return CommonResponse.buildResponseEntity(HttpStatus.CREATED, "성공적으로 가입되었습니다.");
}

@PostMapping("/login")
@Operation(summary = "로그인", description = "정보 일치 시 AccessToken과 RefreshToken을 발급합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))})
public ResponseEntity<?> login(@RequestBody @Valid LoginRequest request) {
LoginResult loginResult = authService.login(request);
return CommonResponse.buildResponseEntity(HttpStatus.OK, "로그인 되었습니다.", loginResult);
}

@PostMapping("/logout")
@Operation(summary = "로그아웃", description = "RefreshToken을 Revoke합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "로그아웃 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))})
public ResponseEntity<?> logout(@AuthenticationPrincipal UserDetails userDetails) {
authService.logout(userDetails.getUsername());
return CommonResponse.buildResponseEntity(HttpStatus.OK, "로그아웃 되었습니다.");
}

@PostMapping("/refresh")
@Operation(summary = "AccessToken 재발급", description = "AccessToken을 재발급합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "재발급 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))})
public ResponseEntity<?> refresh(@RequestBody @Valid RefreshRequest request) {
String newAccessToken = authService.refresh(request.refreshToken());
return CommonResponse.buildResponseEntity(HttpStatus.OK, "AccessToken 재발급 되었습니다.",
Map.of("accessToken", newAccessToken));
}
}
96 changes: 96 additions & 0 deletions src/main/java/apptive/devlog/auth/AuthService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package apptive.devlog.auth;

import apptive.devlog.auth.token.RefreshToken;
import apptive.devlog.auth.token.RefreshTokenRepository;
import apptive.devlog.auth.token.TokenProvider;
import apptive.devlog.exception.EmailAlreadyExistsException;
import apptive.devlog.exception.InvalidPasswordException;
import apptive.devlog.exception.NicknameAlreadyExistsException;
import apptive.devlog.user.User;
import apptive.devlog.user.UserRepository;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import apptive.devlog.auth.dto.*;
import lombok.RequiredArgsConstructor;

import java.time.LocalDateTime;
import java.util.regex.Pattern;

@Service
@RequiredArgsConstructor
@Transactional
public class AuthService {
private final AuthenticationManager authenticationManager;
private final TokenProvider tokenProvider;
private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final PasswordEncoder passwordEncoder;

public void register(RegisterRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new EmailAlreadyExistsException("이미 사용중인 이메일입니다.");
}
if (userRepository.existsByNickname(request.nickname())) {
throw new NicknameAlreadyExistsException("이미 사용중인 닉네임입니다.");
}
if (!validatePassword(request.password())) {
throw new InvalidPasswordException("비밀번호는 대소문자, 특수문자 포함 10자 이상이어야 합니다.");
}

User user = User.builder()
.email(request.email())
.password(passwordEncoder.encode(request.password()))
.name(request.name())
.nickname(request.nickname())
.birth(request.birth())
.gender(request.gender())
.build();
userRepository.save(user);
}

public LoginResult login(LoginRequest request) {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.password()));

User user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new BadCredentialsException("사용자 없음"));

String accessTokenString = tokenProvider.generateAccessToken(user.getEmail());
String refreshTokenString = tokenProvider.generateRefreshToken();
RefreshToken refreshToken = RefreshToken.builder()
.token(refreshTokenString)
.userEmail(request.email())
.expiry(LocalDateTime.now().plusDays(TokenProvider.REFRESH_TOKEN_TTL))
.build();
refreshTokenRepository.deleteByUserEmail(request.email());
refreshTokenRepository.save(refreshToken);

return new LoginResult(accessTokenString, refreshTokenString);
} catch (BadCredentialsException exception) {
throw new BadCredentialsException("이메일 또는 비밀번호가 잘못되었습니다.");
}
}

public void logout(String email) {
refreshTokenRepository.deleteByUserEmail(email);
}

public String refresh(String refreshTokenString) {
RefreshToken refreshToken = refreshTokenRepository.findByToken(refreshTokenString);
if(refreshToken == null || refreshToken.getExpiry().isBefore(LocalDateTime.now()))
throw new BadCredentialsException("RefreshToken이 바르지 않습니다.");

return tokenProvider.generateAccessToken(refreshToken.getUserEmail());
}

private boolean validatePassword(String password) {
Pattern pattern = Pattern.compile("^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[^a-zA-Z0-9]).{10,}$");
return pattern.matcher(password).matches();
}
}
13 changes: 13 additions & 0 deletions src/main/java/apptive/devlog/auth/dto/LoginRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package apptive.devlog.auth.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record LoginRequest(
@NotBlank(message = "이메일은 필수 입력값입니다.")
@Email(message = "이메일 형식이 아닙니다.")
String email,

@NotBlank(message = "비밀번호는 필수 입력값입니다.")
String password
) { }
7 changes: 7 additions & 0 deletions src/main/java/apptive/devlog/auth/dto/LoginResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package apptive.devlog.auth.dto;

public record LoginResult(
String accessToken,
String refreshToken
) {
}
8 changes: 8 additions & 0 deletions src/main/java/apptive/devlog/auth/dto/RefreshRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package apptive.devlog.auth.dto;

import jakarta.validation.constraints.NotBlank;

public record RefreshRequest (
@NotBlank String refreshToken
)
{ }
29 changes: 29 additions & 0 deletions src/main/java/apptive/devlog/auth/dto/RegisterRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package apptive.devlog.auth.dto;

import apptive.devlog.user.User;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

import java.time.LocalDate;

public record RegisterRequest(
@NotBlank(message = "이메일은 필수 입력값입니다.")
@Email(message = "이메일 형식이 아닙니다.")
String email,

@NotBlank(message = "비밀번호는 필수 입력값입니다.")
String password,

@NotBlank(message = "이름은 필수 입력값입니다.")
String name,

@NotBlank(message = "닉네임은 필수 입력값입니다.")
String nickname,

@NotNull(message = "생년월일은 필수 입력값입니다.")
LocalDate birth,

@NotNull(message = "성별은 필수 입력값입니다.")
User.Gender gender
){}
33 changes: 33 additions & 0 deletions src/main/java/apptive/devlog/auth/token/AuthenticationFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package apptive.devlog.auth.token;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
public class AuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;

@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String token = tokenProvider.resolveToken(request);

if (token != null && tokenProvider.validateToken(token)) {
Authentication auth = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}

filterChain.doFilter(request, response);
}
}

Loading
Loading