Skip to content

Commit

Permalink
[feat] 회원가입 및 로그인 구현 (#20)
Browse files Browse the repository at this point in the history
* feat : Swagger JWT 활성화

Swagger에서 JWT token 로그인이 가능하도록 활성화한다.

* chore : jwt 관련 환경 설정

application.yml에 jwt 관련 변수 추가와 build.gradle에 Security 관련 dependency 추가

* chore : SecurityConfig 추가

Spring Security 관련 설정을 할 Security Config class를 추가한다.

* feat : Entity 관련 class 생성

User domain 관련 Entity 추가
User, BaseEntity, UserRepository 생성

* feat : JWT 토큰 관련 로직 작성

JwtRequestFilter, JwtService, JwtToken 생성

* feat : Controller 작성

회원가입, 로그인, 로그아웃, refresh token 재생성을 할 Presentation Layer 함수 작성

* feat : dto 작성

이후 사용할지는 모르겠으나 일단 필요해보이는 dto 모두 작성

* feat : service 로직 작성

비즈니스 메인 로직을 할 Service 로직 작성, 추가적으로 converter와 ErrorStatus 추가

* refactor : package 분류

class package 분류

* fix : import 문제 해결

git merge를 하며 잘 못 import 된 CommonResponse import 문제 해결

* fix : SQL 예약어 충돌 문제 해결

user와 group은 sql 예약어여서 해당 문제 해결

* fix : swagger 및 security 해결

SwaggerConfig, SecurityConfig 수정을 통해 Security에서 회원가입, 로그인을 제외한 나머지 경로로는 토큰 인증을 하도록 하고
swagger에서 걸리도록

* fix : 부분적인 로직 수정

부분적으로 잘못되거나 수정이 필요한 로직 수정

* refactor : 안쓰는 애들 해결

안쓰는 코드, import 등 지저분한 코드 해결

* refactor : SwaggerConfig 주석 삭제

안쓰는 주석 삭제

* refactor : refresh 로직 수정

refresh 실행 시 JwtRequestFilter의 reIssueRefreshToken 사용하도록

* fix : error code numbering

ErrorStatus의 ErrorCode를 넘버링하여 Id처럼 활용할 수 있게 함

* fix(userdetailService) : Array -> List

Collection을 활용하기 위해 Array에서 List로 변경

* fix : CommonResponse 변경

CommonResponse 반환하도록 Controller 반환 타입 변경

* fix : reIssueRefreshToken 위치 변경

refresh 토큰 재발급 함수의 위치를 RequestFilter에서 Service로 변경

* fix : 안쓰는 Dto 삭제

* fix(service) : service 로직 수정

login 시 accessToken과 refreshToken 두 개다 반환하도록 변경, Transactional 적용

만약을 위해 refreshAllToken 함수 작성해놓음

* fix(security) : 허용 엔드포인트 수정

이전 변경사항에 알맞게 security 허용 엔드포인트 수정

* fix(environment) : 환경 변수 처리

환경 변수를 .env file로 처리하여 이에 맞게 수정

* fix : 불필요한 주석 제거

* fix : user, sender 연관관계 설정

* fix : 요청 사항 적용

* fix(test) : test yml에 env config 추가

딱히 쓸모는 없지만 우선 추가해놓음
  • Loading branch information
lsh1215 authored Oct 29, 2024
1 parent 78e5b1a commit ace5bb5
Show file tree
Hide file tree
Showing 20 changed files with 742 additions and 35 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
HELP.md
.env
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
Expand Down
16 changes: 15 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,26 @@ dependencies {
compileOnly 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
implementation 'org.springframework.security:spring-security-crypto'
// implementation('org.springframework.security:spring-security-oauth2-client')
implementation 'org.springframework.security:spring-security-test'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'

// Docker
// Environment
// developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
implementation 'io.github.cdimascio:dotenv-java:2.2.0'

// Database
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
Expand Down
5 changes: 2 additions & 3 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,5 @@ services:
container_name: mysqldb
ports:
- "3306:3306"
environment:
MYSQL_DATABASE: picTalk
MYSQL_ROOT_PASSWORD: "1234"
env_file:
- .env
34 changes: 34 additions & 0 deletions src/main/java/com/pictalk/global/common/BaseEntity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.pictalk.global.common;

import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {

@CreatedDate
@JsonFormat(timezone = "Asia/Seoul")
private LocalDateTime createdAt = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES);

@LastModifiedDate
@JsonFormat(timezone = "Asia/Seoul")
private LocalDateTime updatedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES);

@Column(name = "deleted_at")
@JsonFormat(timezone = "Asia/Seoul")
private LocalDateTime deletedAt;

public void softDelete() {
this.deletedAt = LocalDateTime.now();
}
}
97 changes: 97 additions & 0 deletions src/main/java/com/pictalk/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.pictalk.global.config;


import com.pictalk.global.jwt.JwtRequestFilter;
import com.pictalk.global.jwt.JwtService;
import com.pictalk.user.repository.UserRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer;

import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtRequestFilter jwtFilter;

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();

config.setAllowCredentials(true);
config.setAllowedOrigins(List.of("http://localhost:8080", "http://localhost:5173"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("*"));


UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource()))
.httpBasic(HttpBasicConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/users/signup",
"/users/signin",
"/users/refresh",
"/users/logout",
"/swagger-ui/**",
"/v3/api-docs/**",
"/swagger-ui.html",
"/swagger/**"
).permitAll() // /auth/** 엔드포인트는 인증 없이 접근 가능
.anyRequest().authenticated() // 그 외 모든 요청은 인증 필요
)
// .authorizeHttpRequests(requests ->
// requests.anyRequest().permitAll() // 모든 요청을 모든 사용자에게 허용
// )
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // JWT를 사용하므로 세션 사용 안함

// JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

return http.build();
}

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}

@Bean
public RedirectStrategy redirectStrategy() {
return new DefaultRedirectStrategy(); // 기본 리다이렉트 전략 사용
}
}
26 changes: 23 additions & 3 deletions src/main/java/com/pictalk/global/config/SwaggerConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.Components;

@Configuration
@OpenAPIDefinition(
Expand All @@ -17,8 +19,26 @@
public class SwaggerConfig {

@Bean
public OpenAPI customOpenAPI() {
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")
);

return new OpenAPI()
.addServersItem(new Server().url("/"));
.components(components)
.info(customOpenAPI())
.addSecurityItem(securityRequirement)
.components(components);

}
public io.swagger.v3.oas.models.info.Info customOpenAPI() {
return new io.swagger.v3.oas.models.info.Info()
.title("Pic&Talk API 명세서")
.version("1.0");
}
}
87 changes: 87 additions & 0 deletions src/main/java/com/pictalk/global/jwt/JwtRequestFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.pictalk.global.jwt;

import com.pictalk.global.exception.GeneralException;
import com.pictalk.global.payload.status.ErrorStatus;
import com.pictalk.user.domain.User;
import com.pictalk.user.repository.UserRepository;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@RequiredArgsConstructor
@Slf4j
@Component
public class JwtRequestFilter extends OncePerRequestFilter {

private final JwtService jwtService;
private final UserRepository userRepository;

private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
checkAccessTokenAndAuthentication(request, response, filterChain);
}

public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("checkAccessTokenAndAuthentication() 호출");

// Access Token 추출 및 유효성 검사
jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid)
.flatMap(jwtService::extractEmail) // 이메일 추출
.flatMap(userRepository::findByEmail) // 사용자 정보 조회
.ifPresent(user -> {
log.info("사용자 정보가 발견되었습니다: {}", user);
saveAuthentication((User) user);
log.info("사용자 인증 정보가 저장되었습니다: {}", user);
});

// 다음 필터 체인 실행
filterChain.doFilter(request, response);
}

public void saveAuthentication(User user) {
UserDetails userDetails = org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPassword())
.build();

Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, authoritiesMapper.mapAuthorities(userDetails.getAuthorities()));

SecurityContextHolder.getContext().setAuthentication(authentication);
}

public void validatePassword(String password) {
if (password == null || password.length() < 8) {
throw new GeneralException(ErrorStatus.USER_PASSWORD_NOT_VALID);
}

if (!password.matches(".*[a-z].*")) {
throw new GeneralException(ErrorStatus.USER_PASSWORD_NOT_VALID);
}

if (!password.matches(".*\\d.*")) {
throw new GeneralException(ErrorStatus.USER_PASSWORD_NOT_VALID);
}

if (!password.matches(".*[!@#$%^&*()].*")) {
throw new GeneralException(ErrorStatus.USER_PASSWORD_NOT_VALID);
}
}
}
Loading

0 comments on commit ace5bb5

Please sign in to comment.