From 7b43559850af49eeac3e248f6b9fbc6fafdeb472 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 16:56:56 +0900 Subject: [PATCH 01/27] =?UTF-8?q?=F0=9F=94=A7=20chore:=20Add=20webSecurity?= =?UTF-8?q?=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebSecurityConfig 추가 Related: #9 --- .../vote/common/config/WebSecurityConfig.java | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/main/java/com/ceos/vote/common/config/WebSecurityConfig.java diff --git a/src/main/java/com/ceos/vote/common/config/WebSecurityConfig.java b/src/main/java/com/ceos/vote/common/config/WebSecurityConfig.java new file mode 100644 index 0000000..6af97b0 --- /dev/null +++ b/src/main/java/com/ceos/vote/common/config/WebSecurityConfig.java @@ -0,0 +1,66 @@ +package com.ceos.vote.common.config; + +import com.ceos.vote.exception.JwtAuthenticationEntryPoint; +import com.ceos.vote.jwt.JwtAuthenticationFilter; +import com.ceos.vote.jwt.JwtTokenProvider; +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.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.*; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import com.ceos.vote.exception.JwtAccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + + +@RequiredArgsConstructor +@EnableWebSecurity +@Configuration +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class WebSecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) + throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.httpBasic(HttpBasicConfigurer::disable) + .csrf(CsrfConfigurer::disable) + .formLogin(FormLoginConfigurer::disable) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) + .authorizeRequests() // + .requestMatchers("/", + "/api/auth/signup", + "/api/auth/login/**", + "/api/auth/login").permitAll() + .anyRequest().authenticated() + .and() + .exceptionHandling((exceptionHandling) -> + exceptionHandling + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + ); + + return http.build(); + } + + +} \ No newline at end of file From ced2a6a969c02f4ed43ff6dcee75a0eb5aca4a73 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 17:01:44 +0900 Subject: [PATCH 02/27] :sparkles: feat: Add Security files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PrincipalDetails 관련 파일들 추가 - WebSecurityConfig 파일 이동 Related: #9 --- .../common/security/PrincipalDetails.java | 53 +++++++++++++++++++ .../security/PrincipalDetailsService.java | 29 ++++++++++ .../WebSecurityConfig.java | 6 +-- 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/ceos/vote/common/security/PrincipalDetails.java create mode 100644 src/main/java/com/ceos/vote/common/security/PrincipalDetailsService.java rename src/main/java/com/ceos/vote/common/{config => security}/WebSecurityConfig.java (94%) diff --git a/src/main/java/com/ceos/vote/common/security/PrincipalDetails.java b/src/main/java/com/ceos/vote/common/security/PrincipalDetails.java new file mode 100644 index 0000000..e106a84 --- /dev/null +++ b/src/main/java/com/ceos/vote/common/security/PrincipalDetails.java @@ -0,0 +1,53 @@ +package com.ceos.vote.common.security; + +import com.ceos.vote.domain.member.entity.Member; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; + +@RequiredArgsConstructor +@Getter +public class PrincipalDetails implements UserDetails { + + private final Member user; + + @Override + public Collection getAuthorities() { + Collection authorities = new ArrayList<>(); + authorities.add(() -> user.getRole().getRoleName()); // key: ROLE_권한 + return authorities; + } + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/ceos/vote/common/security/PrincipalDetailsService.java b/src/main/java/com/ceos/vote/common/security/PrincipalDetailsService.java new file mode 100644 index 0000000..54271ba --- /dev/null +++ b/src/main/java/com/ceos/vote/common/security/PrincipalDetailsService.java @@ -0,0 +1,29 @@ +package com.ceos.vote.common.security; + +import com.ceos.vote.domain.member.entity.Member; +import com.ceos.vote.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PrincipalDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public PrincipalDetails loadUserByUsername(String email) throws UsernameNotFoundException { + + Member user = memberRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException(email)); + + if (user != null) { + PrincipalDetails principalDetails = new PrincipalDetails(user); + return principalDetails; + } + return null; + } + +} \ No newline at end of file diff --git a/src/main/java/com/ceos/vote/common/config/WebSecurityConfig.java b/src/main/java/com/ceos/vote/common/security/WebSecurityConfig.java similarity index 94% rename from src/main/java/com/ceos/vote/common/config/WebSecurityConfig.java rename to src/main/java/com/ceos/vote/common/security/WebSecurityConfig.java index 6af97b0..77f5661 100644 --- a/src/main/java/com/ceos/vote/common/config/WebSecurityConfig.java +++ b/src/main/java/com/ceos/vote/common/security/WebSecurityConfig.java @@ -1,8 +1,8 @@ -package com.ceos.vote.common.config; +package com.ceos.vote.common.security; import com.ceos.vote.exception.JwtAuthenticationEntryPoint; -import com.ceos.vote.jwt.JwtAuthenticationFilter; -import com.ceos.vote.jwt.JwtTokenProvider; +import com.ceos.vote.common.jwt.JwtAuthenticationFilter; +import com.ceos.vote.common.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; From fa4d7558d6056c4adae614478ad6cd030a65ed44 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 17:24:40 +0900 Subject: [PATCH 03/27] :sparkles: feat: Add JWT security config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JwtSecurityConfig 추가 - WebSecurityConfig 파일 이동 Related: #9 --- .../vote/common/config/JwtSecurityConfig.java | 20 +++++++++++++++++++ .../common/security/WebSecurityConfig.java | 8 ++++---- 2 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/ceos/vote/common/config/JwtSecurityConfig.java diff --git a/src/main/java/com/ceos/vote/common/config/JwtSecurityConfig.java b/src/main/java/com/ceos/vote/common/config/JwtSecurityConfig.java new file mode 100644 index 0000000..57f006c --- /dev/null +++ b/src/main/java/com/ceos/vote/common/config/JwtSecurityConfig.java @@ -0,0 +1,20 @@ +package com.ceos.vote.common.config; + +import com.ceos.vote.auth.jwt.filter.JwtAuthenticationFilter; +import com.ceos.vote.auth.jwt.provider.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@RequiredArgsConstructor +public class JwtSecurityConfig extends SecurityConfigurerAdapter { + private final JwtTokenProvider jwtTokenProvider; + + @Override + public void configure(HttpSecurity httpSecurity) throws Exception { + JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtTokenProvider); + httpSecurity.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + } +} diff --git a/src/main/java/com/ceos/vote/common/security/WebSecurityConfig.java b/src/main/java/com/ceos/vote/common/security/WebSecurityConfig.java index 77f5661..3e3f704 100644 --- a/src/main/java/com/ceos/vote/common/security/WebSecurityConfig.java +++ b/src/main/java/com/ceos/vote/common/security/WebSecurityConfig.java @@ -1,8 +1,8 @@ package com.ceos.vote.common.security; -import com.ceos.vote.exception.JwtAuthenticationEntryPoint; -import com.ceos.vote.common.jwt.JwtAuthenticationFilter; -import com.ceos.vote.common.jwt.JwtTokenProvider; +import com.ceos.vote.auth.exception.JwtAuthenticationEntryPoint; +import com.ceos.vote.auth.jwt.filter.JwtAuthenticationFilter; +import com.ceos.vote.auth.jwt.provider.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,7 +15,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -import com.ceos.vote.exception.JwtAccessDeniedHandler; +import com.ceos.vote.auth.exception.JwtAccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; From ed497e49982802e262ed41aa100d96b46f160021 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 17:31:16 +0900 Subject: [PATCH 04/27] :sparkles: feat: Add JWT provider & filter Related: #9 --- .../jwt/filter/JwtAuthenticationFilter.java | 52 +++++ .../auth/jwt/provider/JwtTokenProvider.java | 177 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 src/main/java/com/ceos/vote/auth/jwt/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/ceos/vote/auth/jwt/provider/JwtTokenProvider.java diff --git a/src/main/java/com/ceos/vote/auth/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/ceos/vote/auth/jwt/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..f7e5a03 --- /dev/null +++ b/src/main/java/com/ceos/vote/auth/jwt/filter/JwtAuthenticationFilter.java @@ -0,0 +1,52 @@ +package com.ceos.vote.auth.jwt.filter; + +import com.ceos.vote.auth.jwt.provider.JwtTokenProvider; +import com.ceos.vote.exception.ErrorCode; +import com.ceos.vote.exception.CeosException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws IOException, ServletException { + // AT 추출 + String token = resolveToken(request); + + try { + // 유효기간만 제외하고 정상토큰인지 검사 + if (token != null && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (UsernameNotFoundException e) { // 회원 못 찾을 때 + throw new CeosException(ErrorCode.MEMBER_NOT_FOUND); + } + + // 다음 필터로 이동 + filterChain.doFilter(request, response); + } + + // request header에서 토큰 추출 + public String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/ceos/vote/auth/jwt/provider/JwtTokenProvider.java b/src/main/java/com/ceos/vote/auth/jwt/provider/JwtTokenProvider.java new file mode 100644 index 0000000..dc4c9b3 --- /dev/null +++ b/src/main/java/com/ceos/vote/auth/jwt/provider/JwtTokenProvider.java @@ -0,0 +1,177 @@ +package com.ceos.vote.auth.jwt.provider; + +import com.ceos.vote.auth.service.RedisService; +import com.ceos.vote.common.security.PrincipalDetails; +import com.ceos.vote.common.security.PrincipalDetailsService; +import com.ceos.vote.auth.jwt.entity.TokenDto; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.PropertySource; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.security.Key; +import java.util.Date; + +@Component +@Transactional(readOnly = true) +@Slf4j +@PropertySource("classpath:application.yml") +public class JwtTokenProvider { + + private static final String AUTHORITIES_KEY = "role"; + private static final String EMAIL_KEY = "email"; + private final Key signingKey; + private final Long accessTokenValidTime; + private final Long refreshTokenValidTime; + private final RedisService redisService; + private final PrincipalDetailsService principalDetailsService; + + public JwtTokenProvider( + PrincipalDetailsService principalDetailsService, + RedisService redisService, + @Value("${jwt.token.secret}") String secretKey, + @Value("${jwt.token.access-token-validity-in-seconds}") Long accessTokenValidTime, + @Value("${jwt.token.refresh-token-validity-in-seconds}") Long refreshTokenValidTime + ) { + this.principalDetailsService = principalDetailsService; + this.redisService = redisService; + this.accessTokenValidTime = accessTokenValidTime * 1000L; + this.refreshTokenValidTime = refreshTokenValidTime * 1000L; + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.signingKey = Keys.hmacShaKeyFor(keyBytes); + } + + // 토큰 만료 시간 반환 + public long getTokenExpirationTime(String token) { + return parseClaims(token).getExpiration().getTime(); + } + + // 토큰 유효성 검사 (filter에서 사용) + public boolean validateToken(String token) { + + try { + if (redisService.getValues(token) != null + && redisService.getValues(token).equals("logout")) { + return false; + } + Jwts.parserBuilder() + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.info("잘못된 JWT 토큰입니다."); + return false; + } catch (ExpiredJwtException e) { + log.info("만료된 JWT 토큰입니다."); + return true; + } catch (UnsupportedJwtException e) { + log.info("지원되지 않는 JWT 토큰입니다."); + return false; + } catch (IllegalArgumentException e) { + log.info("JWT 토큰이 잘못되었습니다."); + return false; + } + } + + // RT 유효성 검사 + public boolean validateRefreshToken(String token) { + + try { + + Jwts.parserBuilder() + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.info("잘못된 JWT 토큰입니다."); + return false; + } catch (ExpiredJwtException e) { + log.info("만료된 JWT 토큰입니다."); + return false; + } catch (UnsupportedJwtException e) { + log.info("지원되지 않는 JWT 토큰입니다."); + return false; + } catch (IllegalArgumentException e) { + log.info("JWT 토큰이 잘못되었습니다."); + return false; + } + } + + // AT에서 claim 파싱 + private Claims parseClaims(String accessToken) { + try { + + // 올바른 토큰이면 true + return Jwts.parserBuilder().setSigningKey(signingKey).build() + .parseClaimsJws(accessToken) + .getBody(); + } catch (ExpiredJwtException e) { + // 만료 토큰이어도 토큰 정보 꺼내서 return + return e.getClaims(); + } + } + + // 토큰 만료 여부 검사 + public boolean validateTokenOnlyExpired(String token) { + try { + return parseClaims(token) + .getExpiration() + .before(new Date()); + } catch (ExpiredJwtException e) { + return true; + } catch (Exception e) { + return false; + } + } + + // 토큰 생성 + public TokenDto createToken(String email, String authorities) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + accessTokenValidTime); + Date refresh = new Date(now.getTime() + refreshTokenValidTime); + + // TokenDto에 AT, RT 생성해 담기 + String accessToken = Jwts.builder() + .setHeaderParam("typ", "JWT") + .setHeaderParam("alg", "HS512") + .setSubject("access-token") + .claim(EMAIL_KEY, email) + .claim(AUTHORITIES_KEY, authorities) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + String refreshToken = Jwts.builder() + .setHeaderParam("typ", "JWT") + .setHeaderParam("alg", "HS512") + .setSubject("refresh-token") + .setIssuedAt(now) + .setExpiration(refresh) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + return TokenDto.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + // 인증 정보 반환 + public Authentication getAuthentication(String token) { + String email = parseClaims(token).get(EMAIL_KEY).toString(); + PrincipalDetails principalDetails = principalDetailsService.loadUserByUsername(email); + + return new UsernamePasswordAuthenticationToken(principalDetails, "", + principalDetails.getAuthorities()); + } + +} From a7b25bcae166cd6b7d79e5f34fa9fc8185a38c92 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 17:45:51 +0900 Subject: [PATCH 05/27] :wrench: chore: add Token Configuration to yml Related: #9 --- src/main/resources/application.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 771aec8..acef62c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,6 +3,12 @@ spring: profiles: include: secret + jwt: + token: + secret: ${jwt.token.secret} + access-token-validity-in-seconds: ${jwt.token.access-token-validity-in-seconds} + refresh-token-validity-in-seconds: ${jwt.token.refresh-token-validity-in-seconds} + datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://vote-rds.csucgot1eysx.ap-northeast-2.rds.amazonaws.com:3306/votedb From eb442c5b6f29b8b9037937b11a0fdb0276c97f38 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 17:47:19 +0900 Subject: [PATCH 06/27] :wrench: chore: Inject Spring Security & JWT dependency Related: #9 --- build.gradle | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/build.gradle b/build.gradle index bc7bfca..30d5e5a 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,15 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security:3.0.4' + testImplementation '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' + } tasks.named('test') { From 5e53f294d18f5c10e545153c2276d63136cf0257 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 17:52:20 +0900 Subject: [PATCH 07/27] :wrench: chore: Inject Redis dependency Related: #10 --- build.gradle | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build.gradle b/build.gradle index 30d5e5a..613c1bb 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,11 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation('it.ozimov:embedded-redis:0.7.3') { exclude group: "org.slf4j", module: "slf4j-simple" } + + } tasks.named('test') { From 2d8f076c32a8980e50d681b898a8f30092db3394 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:14:16 +0900 Subject: [PATCH 08/27] :wrench: feat: create Signup API in auth controller Related: #10 --- .../vote/auth/controller/AuthController.java | 32 +++++++++++++++++++ .../ceos/vote/auth/jwt/entity/TokenDto.java | 20 ++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/main/java/com/ceos/vote/auth/controller/AuthController.java create mode 100644 src/main/java/com/ceos/vote/auth/jwt/entity/TokenDto.java diff --git a/src/main/java/com/ceos/vote/auth/controller/AuthController.java b/src/main/java/com/ceos/vote/auth/controller/AuthController.java new file mode 100644 index 0000000..f1262a5 --- /dev/null +++ b/src/main/java/com/ceos/vote/auth/controller/AuthController.java @@ -0,0 +1,32 @@ +package com.ceos.vote.auth.controller; + +import com.ceos.vote.auth.service.AuthService; +import com.ceos.vote.common.dto.NormalResponseDto; +import com.ceos.vote.auth.jwt.entity.TokenDto; +import com.ceos.vote.auth.jwt.entity.LoginRequestDto; +import com.ceos.vote.domain.member.dto.MemberRequestDto; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.server.Cookie; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/app/auth") +@RequiredArgsConstructor +public class AuthController { + + private final long COOKIE_EXPIRATION = 7776000; // 90일 + + private final AuthService authService; + + // 회원가입 + @PostMapping("/signup") + public ResponseEntity join(@RequestBody @Valid MemberRequestDto requestDto) { + authService.joinMember(requestDto); + return ResponseEntity.ok(NormalResponseDto.success()); + } + + +} \ No newline at end of file diff --git a/src/main/java/com/ceos/vote/auth/jwt/entity/TokenDto.java b/src/main/java/com/ceos/vote/auth/jwt/entity/TokenDto.java new file mode 100644 index 0000000..b33bb67 --- /dev/null +++ b/src/main/java/com/ceos/vote/auth/jwt/entity/TokenDto.java @@ -0,0 +1,20 @@ +package com.ceos.vote.auth.jwt.entity; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +public class TokenDto { + + private String accessToken; + private String refreshToken; + + public TokenDto(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } +} \ No newline at end of file From 5d3a2840950fe232bf266cd10a3d92f7436805db Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:15:46 +0900 Subject: [PATCH 09/27] :wrench: feat: create Signup API in auth service Related: #10 --- .../ceos/vote/auth/service/AuthService.java | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/main/java/com/ceos/vote/auth/service/AuthService.java diff --git a/src/main/java/com/ceos/vote/auth/service/AuthService.java b/src/main/java/com/ceos/vote/auth/service/AuthService.java new file mode 100644 index 0000000..aa6acba --- /dev/null +++ b/src/main/java/com/ceos/vote/auth/service/AuthService.java @@ -0,0 +1,57 @@ +package com.ceos.vote.auth.service; + +import com.ceos.vote.auth.jwt.entity.TokenDto; +import com.ceos.vote.exception.ErrorCode; +import com.ceos.vote.exception.CeosException; +import com.ceos.vote.auth.jwt.provider.JwtTokenProvider; +import com.ceos.vote.auth.jwt.entity.LoginRequestDto; +import com.ceos.vote.domain.member.dto.MemberRequestDto; +import com.ceos.vote.domain.member.entity.Member; +import com.ceos.vote.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.stream.Collectors; + + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final AuthenticationManagerBuilder authenticationManager; + private final JwtTokenProvider jwtTokenProvider; + private final RedisService redisService; + private final PasswordEncoder passwordEncoder; + + private final MemberRepository memberRepository; + private final String SERVER = "Server"; + + // 회원가입 + @Transactional + public void joinMember(MemberRequestDto requestDto) { + + // 이메일, ID 중복 검사 + if (findUserByEmail(requestDto.getEmail())) + throw new CeosException(ErrorCode.ALREADY_MEMBER_EMAIL); + + if (findUserByUserid(requestDto.getUserid())) + throw new CeosException(ErrorCode.ALREADY_MEMBER_ID); + + Member member = requestDto.toMember(passwordEncoder); + memberRepository.save(member); + } + + + + public boolean findUserByEmail(String email) { return memberRepository.existsByEmail(email);} + public boolean findUserByUserid(String userid) { return memberRepository.existsByUserid(userid);} + +} \ No newline at end of file From c0e2dc69024f02f1ebc58708903812fcb4d3acce Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:18:01 +0900 Subject: [PATCH 10/27] :wrench: feat: create response Dtos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원가입 요청 Dto 생성 - Normal 응답 Dto 생성 Related: #10 --- .../vote/common/dto/NormalResponseDto.java | 26 +++++++++ .../domain/member/dto/MemberRequestDto.java | 57 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/main/java/com/ceos/vote/common/dto/NormalResponseDto.java create mode 100644 src/main/java/com/ceos/vote/domain/member/dto/MemberRequestDto.java diff --git a/src/main/java/com/ceos/vote/common/dto/NormalResponseDto.java b/src/main/java/com/ceos/vote/common/dto/NormalResponseDto.java new file mode 100644 index 0000000..0427452 --- /dev/null +++ b/src/main/java/com/ceos/vote/common/dto/NormalResponseDto.java @@ -0,0 +1,26 @@ +package com.ceos.vote.common.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class NormalResponseDto { + private String status; + private String message; + + protected NormalResponseDto(String status) { + this.status = status; + } + + public static NormalResponseDto success() { + return new NormalResponseDto("SUCCESS"); + } + + public static NormalResponseDto fail() { + return new NormalResponseDto("FAIL"); + } + +} \ No newline at end of file diff --git a/src/main/java/com/ceos/vote/domain/member/dto/MemberRequestDto.java b/src/main/java/com/ceos/vote/domain/member/dto/MemberRequestDto.java new file mode 100644 index 0000000..fb43da5 --- /dev/null +++ b/src/main/java/com/ceos/vote/domain/member/dto/MemberRequestDto.java @@ -0,0 +1,57 @@ +package com.ceos.vote.domain.member.dto; + +import com.ceos.vote.domain.member.entity.Member; +import com.ceos.vote.domain.team.entity.Team; +import com.ceos.vote.domain.devPart.entity.DevPart; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class MemberRequestDto { + + @NotBlank(message = "닉네임은 필수 입력값입니다.") + private String username; + + @NotBlank(message = "ID는 필수 입력값입니다.") + private String userid; + + @NotBlank(message = "이메일은 필수 입력값입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + private String email; + + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[$@$!%*#?&])[A-Za-z\\d$@$!%*#?&]{8,16}$", + message = "비밀번호는 8~16자리수여야 합니다. " + + "영문 대소문자, 숫자, 특수문자를 1개 이상 포함해야 합니다.") + private String password; + + private Boolean voteFlagMember; + private Boolean voteFlagTeam; + private Integer voteCnt; + + private Team team; + private DevPart devPart; + + public Member toMember(PasswordEncoder passwordEncoder) { + return Member.builder() + .username(username) + .userid(userid) + .password(passwordEncoder.encode(password)) + .email(email) + .voteFlagMember(voteFlagMember) + .voteFlagTeam(voteFlagTeam) + .voteCnt(voteCnt) + .team(team) + .devPart(devPart) + .build(); + } + +} From 4af4f76265b53169b4958c7ed4065df62f0d7596 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:20:56 +0900 Subject: [PATCH 11/27] :sparkles: update: update Member Entity & Repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Member 엔티티에 Role 추가 - Member 레포지토리에 메서드 추가 Related: #10 --- .../vote/domain/member/entity/Member.java | 20 +++++++++---------- .../member/repository/MemberRepository.java | 7 +++++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/ceos/vote/domain/member/entity/Member.java b/src/main/java/com/ceos/vote/domain/member/entity/Member.java index 881ab77..f079159 100644 --- a/src/main/java/com/ceos/vote/domain/member/entity/Member.java +++ b/src/main/java/com/ceos/vote/domain/member/entity/Member.java @@ -1,15 +1,8 @@ package com.ceos.vote.domain.member.entity; -import com.ceos.vote.domain.dev_part.entity.DevPart; +import com.ceos.vote.domain.devPart.entity.DevPart; import com.ceos.vote.domain.team.entity.Team; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -43,11 +36,14 @@ public class Member { @JoinColumn(name = "dev_part_id") private DevPart devPart; + @Enumerated(EnumType.STRING) + private Role role; + @Builder - public Member(Long id, String username, String userId, String email, String password, Boolean voteFlagMember, Boolean voteFlagTeam, Integer voteCnt, Team team, DevPart devPart) { + public Member(Long id, String username, String userid, String email, String password, Boolean voteFlagMember, Boolean voteFlagTeam, Integer voteCnt, Team team, DevPart devPart) { this.id = id; this.username = username; - this.userid = userId; + this.userid = userid; this.email = email; this.password = password; this.voteFlagMember = voteFlagMember; @@ -55,5 +51,7 @@ public Member(Long id, String username, String userId, String email, String pass this.voteCnt = voteCnt; this.team = team; this.devPart = devPart; + this.role = Role.ROLE_USER; + } } diff --git a/src/main/java/com/ceos/vote/domain/member/repository/MemberRepository.java b/src/main/java/com/ceos/vote/domain/member/repository/MemberRepository.java index 774a075..8e38141 100644 --- a/src/main/java/com/ceos/vote/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/ceos/vote/domain/member/repository/MemberRepository.java @@ -4,6 +4,13 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface MemberRepository extends JpaRepository { + + Optional findByEmail(String email); + boolean existsByEmail(String email); + boolean existsByUserid(String userid); + } From 825563759e0197e116b53b37ecbfe872ccd983e8 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:22:27 +0900 Subject: [PATCH 12/27] :recycle: refactor: revise DevPart Entity & Repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package, import문 디렉토리명 리팩토링 Related: #10 --- .../java/com/ceos/vote/domain/devPart/entity/DevPart.java | 2 +- .../vote/domain/devPart/repository/DevPartRepository.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ceos/vote/domain/devPart/entity/DevPart.java b/src/main/java/com/ceos/vote/domain/devPart/entity/DevPart.java index bf2a163..c8b6a1b 100644 --- a/src/main/java/com/ceos/vote/domain/devPart/entity/DevPart.java +++ b/src/main/java/com/ceos/vote/domain/devPart/entity/DevPart.java @@ -1,4 +1,4 @@ -package com.ceos.vote.domain.dev_part.entity; +package com.ceos.vote.domain.devPart.entity; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; diff --git a/src/main/java/com/ceos/vote/domain/devPart/repository/DevPartRepository.java b/src/main/java/com/ceos/vote/domain/devPart/repository/DevPartRepository.java index a6f76d4..db7e826 100644 --- a/src/main/java/com/ceos/vote/domain/devPart/repository/DevPartRepository.java +++ b/src/main/java/com/ceos/vote/domain/devPart/repository/DevPartRepository.java @@ -1,6 +1,6 @@ -package com.ceos.vote.domain.dev_part.repository; +package com.ceos.vote.domain.devPart.repository; -import com.ceos.vote.domain.dev_part.entity.DevPart; +import com.ceos.vote.domain.devPart.entity.DevPart; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; From 766dc4c58533c3fa0f4d76ab721ef04589240614 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:24:14 +0900 Subject: [PATCH 13/27] :sparkles: feat: create Login API in auth controller Related: #10 --- .../vote/auth/controller/AuthController.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main/java/com/ceos/vote/auth/controller/AuthController.java b/src/main/java/com/ceos/vote/auth/controller/AuthController.java index f1262a5..08edb5a 100644 --- a/src/main/java/com/ceos/vote/auth/controller/AuthController.java +++ b/src/main/java/com/ceos/vote/auth/controller/AuthController.java @@ -28,5 +28,23 @@ public ResponseEntity join(@RequestBody @Valid MemberRequestD return ResponseEntity.ok(NormalResponseDto.success()); } + // 로그인 + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequestDto loginRequest) { + + TokenDto tokenDto = authService.login(loginRequest); + + HttpCookie httpCookie = ResponseCookie.from("refresh-token", tokenDto.getRefreshToken()) + .maxAge(COOKIE_EXPIRATION) + .httpOnly(true) + .secure(true) + .sameSite(Cookie.SameSite.NONE.attributeValue()) //서드파티 쿠키 사용 허용 + .build(); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, httpCookie.toString()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + tokenDto.getAccessToken()) + .build(); + } + } \ No newline at end of file From 9ee9804a4e2dfc7e863c270bbca79546a419aaf1 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:26:04 +0900 Subject: [PATCH 14/27] :sparkles: feat: create Login API in auth service Related: #10 --- .../com/ceos/vote/auth/service/AuthService.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/com/ceos/vote/auth/service/AuthService.java b/src/main/java/com/ceos/vote/auth/service/AuthService.java index aa6acba..c13fc77 100644 --- a/src/main/java/com/ceos/vote/auth/service/AuthService.java +++ b/src/main/java/com/ceos/vote/auth/service/AuthService.java @@ -50,6 +50,19 @@ public void joinMember(MemberRequestDto requestDto) { } + // 로그인 + @Transactional + public TokenDto login(LoginRequestDto loginRequestDto) { + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(loginRequestDto.getEmail(), loginRequestDto.getPassword()); + + Authentication authentication = authenticationManager.getObject() + .authenticate(authenticationToken); + + SecurityContextHolder.getContext().setAuthentication(authentication); + return generateToken(SERVER, authentication.getName(), getAuthorities(authentication)); + } + public boolean findUserByEmail(String email) { return memberRepository.existsByEmail(email);} public boolean findUserByUserid(String userid) { return memberRepository.existsByUserid(userid);} From 6291a68f5eb12cc6ab7a4daacf94ec445838e10d Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:26:49 +0900 Subject: [PATCH 15/27] :sparkles: feat: create Login request Dto Related: #10 --- .../vote/auth/jwt/entity/LoginRequestDto.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/main/java/com/ceos/vote/auth/jwt/entity/LoginRequestDto.java diff --git a/src/main/java/com/ceos/vote/auth/jwt/entity/LoginRequestDto.java b/src/main/java/com/ceos/vote/auth/jwt/entity/LoginRequestDto.java new file mode 100644 index 0000000..9e94401 --- /dev/null +++ b/src/main/java/com/ceos/vote/auth/jwt/entity/LoginRequestDto.java @@ -0,0 +1,16 @@ +package com.ceos.vote.auth.jwt.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class LoginRequestDto { + private String email; + + private String password; +} From e29b8a4fc40693eaffff5e92dbb43d32807e4a20 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:30:41 +0900 Subject: [PATCH 16/27] :sparkles: feat: create Token validate & reissue in auth controller Related: #10 --- .../vote/auth/controller/AuthController.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/main/java/com/ceos/vote/auth/controller/AuthController.java b/src/main/java/com/ceos/vote/auth/controller/AuthController.java index 08edb5a..b0f4888 100644 --- a/src/main/java/com/ceos/vote/auth/controller/AuthController.java +++ b/src/main/java/com/ceos/vote/auth/controller/AuthController.java @@ -46,5 +46,47 @@ public ResponseEntity login(@RequestBody LoginRequestDto loginRequest) { .build(); } + // 토큰 유효성 검사 + @PostMapping("/validate") + public ResponseEntity validate(@RequestHeader("Authorization") String requestAccessToken) { + if (!authService.validate(requestAccessToken)) { + return ResponseEntity.ok().build(); + } else { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + } + + // 토큰 재발급 + @PostMapping("/reissue") + public ResponseEntity reissue(@CookieValue(name = "refresh-token") String requestRefreshToken, + @RequestHeader("Authorization") String requestAccessToken) { + + TokenDto newAuthToken = authService.reissue(requestAccessToken, requestRefreshToken); + + if (newAuthToken != null) { + // 새로운 토큰 발급, 반환 + ResponseCookie responseCookie = ResponseCookie.from("refresh-token", newAuthToken.getRefreshToken()) + .maxAge(COOKIE_EXPIRATION) + .httpOnly(true) + .secure(true) + .sameSite(Cookie.SameSite.NONE.attributeValue()) + .build(); + return ResponseEntity.status(HttpStatus.OK) + .header(HttpHeaders.SET_COOKIE, responseCookie.toString()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + newAuthToken.getAccessToken()) + .build(); + } else { + // Refresh Token이 탈취 가능할 때 쿠키 삭제하고 재로그인 + ResponseCookie responseCookie = ResponseCookie.from("refresh-token", "") + .maxAge(0) + .path("/") + .build(); + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .header(HttpHeaders.SET_COOKIE, responseCookie.toString()) + .build(); + } + } + } \ No newline at end of file From adf8674a23d24b0e724f4bf7303faaa31dcb72dc Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:31:08 +0900 Subject: [PATCH 17/27] :sparkles: feat: create Logout API in auth controller Related: #10 --- .../vote/auth/controller/AuthController.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main/java/com/ceos/vote/auth/controller/AuthController.java b/src/main/java/com/ceos/vote/auth/controller/AuthController.java index b0f4888..fa4029b 100644 --- a/src/main/java/com/ceos/vote/auth/controller/AuthController.java +++ b/src/main/java/com/ceos/vote/auth/controller/AuthController.java @@ -88,5 +88,22 @@ public ResponseEntity reissue(@CookieValue(name = "refresh-token") String req } } + // 로그아웃 + @PostMapping("/logout") + public ResponseEntity logout(@RequestHeader("Authorization") String requestAccessToken) { + + // Access Token을 무효화하여 로그아웃 처리 + authService.logout(requestAccessToken); + + ResponseCookie responseCookie = ResponseCookie.from("refresh-token", "") + .maxAge(0) + .path("/") + .build(); + + + return ResponseEntity.status(HttpStatus.OK) + .header(HttpHeaders.SET_COOKIE, responseCookie.toString()) + .build(); + } } \ No newline at end of file From 4c8b86443c526e65dd57dbb3b60f4f61594085b6 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:33:07 +0900 Subject: [PATCH 18/27] :sparkles: feat: create Token Logics in auth service Related: #10 --- .../ceos/vote/auth/service/AuthService.java | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/main/java/com/ceos/vote/auth/service/AuthService.java b/src/main/java/com/ceos/vote/auth/service/AuthService.java index c13fc77..c6553d1 100644 --- a/src/main/java/com/ceos/vote/auth/service/AuthService.java +++ b/src/main/java/com/ceos/vote/auth/service/AuthService.java @@ -64,6 +64,99 @@ public TokenDto login(LoginRequestDto loginRequestDto) { } + // Access Token가 만료만 된 (유효한) 토큰인지 검사 + public boolean validate(String requestAccessTokenInHeader) { + String requestRefreshToken = resolveToken(requestAccessTokenInHeader); + return jwtTokenProvider.validateTokenOnlyExpired(requestRefreshToken); + } + + + // 토큰 재발급: validate가 true일 때 access, refresh 모두 재발급 + @Transactional + public TokenDto reissue(String requestAccessTokenInHeader, String requestRefreshToken) { + String accessToken = resolveToken(requestAccessTokenInHeader); + + Authentication authentication = jwtTokenProvider.getAuthentication(accessToken); + + String principal = getPrincipal(accessToken); + + String redisRefreshToken = redisService.getValues("refresh-token:" + SERVER + ":" + principal); + + // 저장된 refresh 없으면 재로그인 요청 + if (redisRefreshToken == null) { + return null; + } + + // refresh가 redis와 다르거나 유효하지 않으면 삭제하고 재로그인 요청 + if (!jwtTokenProvider.validateRefreshToken(requestRefreshToken) || + !redisRefreshToken.equals(requestRefreshToken)) { + redisService.deleteValues("refresh-token:" + SERVER + ":" + principal); + return null; + } + + SecurityContextHolder.getContext().setAuthentication(authentication); + String authorities = getAuthorities(authentication); + + // 기존 refresh 삭제하고 토큰 재발급 및 저장 + redisService.deleteValues("refresh-token:" + SERVER + ":" + principal); + TokenDto tokenDto = jwtTokenProvider.createToken(principal, authorities); + saveRefreshToken(SERVER, principal, tokenDto.getRefreshToken()); + return tokenDto; + + } + + // 토큰 발급 + @Transactional + public TokenDto generateToken(String provider, String email, String authorities) { + //refresh 이미 있을 경우 삭제 + if (redisService.getValues("refresh-token:" + provider + ":" + email) != null) { + redisService.deleteValues("refresh-token:" + provider + ":" + email); + } + + // 토큰 재발급 후 저장 + TokenDto authToken = jwtTokenProvider.createToken(email, authorities); + saveRefreshToken(provider, email, authToken.getRefreshToken()); + + return authToken; + } + + + // RT를 Redis에 저장 + @Transactional + public void saveRefreshToken(String provider, String principal, String refreshToken) { + // 저장할 Redis 키를 생성합 + String redisKey = "refresh-token:" + provider + ":" + principal; + + redisService.setValuesWithTimeout(redisKey, + refreshToken, + jwtTokenProvider.getTokenExpirationTime(refreshToken)); + } + + // 권한 이름 가져오기 + public String getAuthorities(Authentication authentication) { + // 권한 이름들을 ","로 구분하여 하나의 문자열로 변환합니다. + return authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + } + + + // AT로부터 principal 추출 + public String getPrincipal(String requestAccessToken) { + return jwtTokenProvider.getAuthentication(requestAccessToken).getName(); + } + + + // "Bearer {AT}"에서 {AT} 추출 + public String resolveToken(String requestAccessTokenInHeader) { + if (requestAccessTokenInHeader != null && requestAccessTokenInHeader.startsWith("Bearer ")) { + return requestAccessTokenInHeader.substring(7); + } + return null; + } + + + public boolean findUserByEmail(String email) { return memberRepository.existsByEmail(email);} public boolean findUserByUserid(String userid) { return memberRepository.existsByUserid(userid);} From 119a8225cb066a60f5aa9fcede75997e5f989692 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:33:32 +0900 Subject: [PATCH 19/27] :sparkles: feat: create Logout API in auth service Related: #10 --- .../com/ceos/vote/auth/service/AuthService.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main/java/com/ceos/vote/auth/service/AuthService.java b/src/main/java/com/ceos/vote/auth/service/AuthService.java index c6553d1..8ed98e8 100644 --- a/src/main/java/com/ceos/vote/auth/service/AuthService.java +++ b/src/main/java/com/ceos/vote/auth/service/AuthService.java @@ -156,6 +156,23 @@ public String resolveToken(String requestAccessTokenInHeader) { } + // 로그아웃 + @Transactional + public void logout(String requestAccessTokenInHeader) { + String requestAccessToken = resolveToken(requestAccessTokenInHeader); + String principal = getPrincipal(requestAccessToken); + + // Redis 또는 다른 저장소에서 관련 정보 삭제 + String refreshToken = redisService.getValues("refresh-token:" + SERVER + ":" + principal); + if (refreshToken != null) { + redisService.deleteValues("refresh-token:" + SERVER + ":" + principal); + } + + long expiration = jwtTokenProvider.getTokenExpirationTime(requestAccessToken); + redisService.setValuesWithTimeout(requestAccessToken, + "logout", + expiration); + } public boolean findUserByEmail(String email) { return memberRepository.existsByEmail(email);} public boolean findUserByUserid(String userid) { return memberRepository.existsByUserid(userid);} From 84dae7603f8e107c910147274163f72703ebcf22 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:34:11 +0900 Subject: [PATCH 20/27] :sparkles: feat: create Redis service Related: #10 --- .../ceos/vote/auth/service/RedisService.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/main/java/com/ceos/vote/auth/service/RedisService.java diff --git a/src/main/java/com/ceos/vote/auth/service/RedisService.java b/src/main/java/com/ceos/vote/auth/service/RedisService.java new file mode 100644 index 0000000..fab76b3 --- /dev/null +++ b/src/main/java/com/ceos/vote/auth/service/RedisService.java @@ -0,0 +1,35 @@ +package com.ceos.vote.auth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.concurrent.TimeUnit; + +@Service +@Transactional(readOnly = false) +@RequiredArgsConstructor +public class RedisService { + private final RedisTemplate redisTemplate; + + @Transactional + public void setValues(String key, String value) { + redisTemplate.opsForValue().set(key, value); + } + + @Transactional + public void setValuesWithTimeout(String key, String value, long timeout) { + redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.MILLISECONDS); + } + + public String getValues(String key) { + return redisTemplate.opsForValue().get(key); + } + + @Transactional + public void deleteValues(String key) { + redisTemplate.delete(key); + } +} + From ac782474bd7ddce8f8e194b2f88400600b37f8e6 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:34:34 +0900 Subject: [PATCH 21/27] :sparkles: feat: create Member Role entity Related: #10 --- .../com/ceos/vote/domain/member/entity/Role.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/main/java/com/ceos/vote/domain/member/entity/Role.java diff --git a/src/main/java/com/ceos/vote/domain/member/entity/Role.java b/src/main/java/com/ceos/vote/domain/member/entity/Role.java new file mode 100644 index 0000000..098c767 --- /dev/null +++ b/src/main/java/com/ceos/vote/domain/member/entity/Role.java @@ -0,0 +1,13 @@ +package com.ceos.vote.domain.member.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Role { + ROLE_USER("회원"), + ROLE_ADMIN("관리자"); + + private final String roleName; +} From f59146a71bb1f1ead8f71b4be766c5d83f8617b3 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:35:03 +0900 Subject: [PATCH 22/27] :sparkles: feat: create CurrentUser annotation Related: #10 --- src/main/java/com/ceos/vote/auth/CurrentUser.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/ceos/vote/auth/CurrentUser.java diff --git a/src/main/java/com/ceos/vote/auth/CurrentUser.java b/src/main/java/com/ceos/vote/auth/CurrentUser.java new file mode 100644 index 0000000..3ebd1ca --- /dev/null +++ b/src/main/java/com/ceos/vote/auth/CurrentUser.java @@ -0,0 +1,12 @@ +package com.ceos.vote.auth; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import java.lang.annotation.*; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : user") +public @interface CurrentUser { +} From b86a21f4554b0ea3195451b913b28271c020790b Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:44:35 +0900 Subject: [PATCH 23/27] :sparkles: feat: create JWT access denied handler Related: #11 --- .../exception/JwtAccessDeniedHandler.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/main/java/com/ceos/vote/auth/exception/JwtAccessDeniedHandler.java diff --git a/src/main/java/com/ceos/vote/auth/exception/JwtAccessDeniedHandler.java b/src/main/java/com/ceos/vote/auth/exception/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..02f2986 --- /dev/null +++ b/src/main/java/com/ceos/vote/auth/exception/JwtAccessDeniedHandler.java @@ -0,0 +1,22 @@ +package com.ceos.vote.auth.exception; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + + // 필요한 권한 없이 접근시 403 error + response.sendError(HttpServletResponse.SC_FORBIDDEN); + + } +} \ No newline at end of file From 5e314cc5392b0bc842768f10e3b5ff253d9fe4b9 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:45:17 +0900 Subject: [PATCH 24/27] :sparkles: feat: create JWT authentication entry point handler Related: #11 --- .../JwtAuthenticationEntryPoint.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/java/com/ceos/vote/auth/exception/JwtAuthenticationEntryPoint.java diff --git a/src/main/java/com/ceos/vote/auth/exception/JwtAuthenticationEntryPoint.java b/src/main/java/com/ceos/vote/auth/exception/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..9b4b9ff --- /dev/null +++ b/src/main/java/com/ceos/vote/auth/exception/JwtAuthenticationEntryPoint.java @@ -0,0 +1,20 @@ +package com.ceos.vote.auth.exception; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authenticationException) throws IOException { + // 유효하지 않은 자격증명일 때 401 error + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } +} \ No newline at end of file From b9287cc882be4cd6da712b08955410d449d73659 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Wed, 13 Dec 2023 20:45:52 +0900 Subject: [PATCH 25/27] :sparkles: feat: create Custom error Related: #11 --- .../ceos/vote/exception/CeosException.java | 30 +++++++++++++++++++ .../com/ceos/vote/exception/ErrorCode.java | 21 +++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/main/java/com/ceos/vote/exception/CeosException.java create mode 100644 src/main/java/com/ceos/vote/exception/ErrorCode.java diff --git a/src/main/java/com/ceos/vote/exception/CeosException.java b/src/main/java/com/ceos/vote/exception/CeosException.java new file mode 100644 index 0000000..19be03d --- /dev/null +++ b/src/main/java/com/ceos/vote/exception/CeosException.java @@ -0,0 +1,30 @@ +package com.ceos.vote.exception; + +import lombok.Getter; + +@Getter +public class CeosException extends RuntimeException { + + private int status; + private String message; + private String solution; + + public CeosException(ErrorCode errorCode) { + this.message = errorCode.getMessage(); + this.status = errorCode.getHttpStatus().value(); + this.solution = errorCode.getSolution(); + } + + public CeosException(ErrorCode errorCode, String message) { + this.message = message; + this.status = errorCode.getHttpStatus().value(); + this.solution = errorCode.getSolution(); + } + + public CeosException(ErrorCode errorCode, String message, String solution) { + this.message = message; + this.status = errorCode.getHttpStatus().value(); + this.solution = solution; + } + +} diff --git a/src/main/java/com/ceos/vote/exception/ErrorCode.java b/src/main/java/com/ceos/vote/exception/ErrorCode.java new file mode 100644 index 0000000..d1afe08 --- /dev/null +++ b/src/main/java/com/ceos/vote/exception/ErrorCode.java @@ -0,0 +1,21 @@ +package com.ceos.vote.exception; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "유저 정보를 찾지 못했습니다.", "email 과 password 를 올바르게 입력했는지 확인해주세요"), + ALREADY_MEMBER_EMAIL(HttpStatus.CONFLICT, "이미 존재하는 유저 정보입니다.", "다른 이메일로 가입해주세요."), + ALREADY_MEMBER_ID(HttpStatus.CONFLICT, "이미 존재하는 유저 정보입니다.", "다른 ID로 가입해주세요."), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "아이디 혹은 비밀번호가 틀렸습니다.", "다시 시도해주세요."), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 액세스 토큰입니다.", "다시 로그인해주세요."); + + private final HttpStatus httpStatus; + private final String message; + private final String solution; + +} \ No newline at end of file From 7adf916e88c1396a5ce1826e9aa9197a08cc03ab Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Thu, 14 Dec 2023 00:42:47 +0900 Subject: [PATCH 26/27] :sparkles: update: change endpoint for WebSecurityConfig Related: #10 --- .../com/ceos/vote/common/security/WebSecurityConfig.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ceos/vote/common/security/WebSecurityConfig.java b/src/main/java/com/ceos/vote/common/security/WebSecurityConfig.java index 3e3f704..e6eb7b7 100644 --- a/src/main/java/com/ceos/vote/common/security/WebSecurityConfig.java +++ b/src/main/java/com/ceos/vote/common/security/WebSecurityConfig.java @@ -48,9 +48,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) .authorizeRequests() // .requestMatchers("/", - "/api/auth/signup", - "/api/auth/login/**", - "/api/auth/login").permitAll() + "/app/auth/signup", + "/app/auth/login/**", + "/app/auth/login").permitAll() .anyRequest().authenticated() .and() .exceptionHandling((exceptionHandling) -> From ec3eb8b5e498b1d624ff740d7ae8ff359ffe6838 Mon Sep 17 00:00:00 2001 From: yeni-choi Date: Thu, 14 Dec 2023 01:37:51 +0900 Subject: [PATCH 27/27] :sparkles: chore: set ENV in yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - yml에 환경변수 적용 - redis 추가 Related: #12 --- .../vote/domain/member/dto/MemberRequestDto.java | 6 +++--- src/main/resources/application.yml | 14 ++++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/ceos/vote/domain/member/dto/MemberRequestDto.java b/src/main/java/com/ceos/vote/domain/member/dto/MemberRequestDto.java index fb43da5..7a5bcd5 100644 --- a/src/main/java/com/ceos/vote/domain/member/dto/MemberRequestDto.java +++ b/src/main/java/com/ceos/vote/domain/member/dto/MemberRequestDto.java @@ -33,9 +33,9 @@ public class MemberRequestDto { "영문 대소문자, 숫자, 특수문자를 1개 이상 포함해야 합니다.") private String password; - private Boolean voteFlagMember; - private Boolean voteFlagTeam; - private Integer voteCnt; + private Boolean voteFlagMember = false; + private Boolean voteFlagTeam = false; + private Integer voteCnt = 0; private Team team; private DevPart devPart; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index acef62c..e8ed6c3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,9 +11,15 @@ spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://vote-rds.csucgot1eysx.ap-northeast-2.rds.amazonaws.com:3306/votedb - username: admin - password: 12345678 + url: ${db.url} + username: ${db.username} + password: ${db.password} + + data: + redis: + host: ${redis.host} + port: 6379 + password: root1234 jpa: database: mysql @@ -42,4 +48,4 @@ server: springdoc: swagger-ui: - path: /swagger-vote.html + path: /swagger-vote.html \ No newline at end of file