diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..951869b --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ +settings.gradle +/build/** +/.gradle/** +gradlew +gradlew.bat + + + + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +*.exe +*.dll \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..3d70803 --- /dev/null +++ b/build.gradle @@ -0,0 +1,61 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '2.7.16' + id 'io.spring.dependency-management' version '1.0.15.RELEASE' +} + +group = 'com.gdsc-ys' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '11' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + + implementation group: 'org.springdoc', name: 'springdoc-openapi-ui', version: '1.6.12' + implementation 'net.minidev:json-smart:2.4.9' + + compileOnly 'org.projectlombok:lombok:1.18.30' + runtimeOnly 'com.h2database:h2' + annotationProcessor 'org.projectlombok:lombok:1.18.30' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + + // jwt + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + + implementation "com.querydsl:querydsl-jpa:5.0.0" + annotationProcessor "com.querydsl:querydsl-apt:5.0.0" +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/git.ignore b/git.ignore new file mode 100644 index 0000000..27a29bc --- /dev/null +++ b/git.ignore @@ -0,0 +1,45 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ +settings.gradle +/build/** +/.gradle/** +gradlew +gradlew.bat + + + + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..033e24c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..fface13 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,11 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +<<<<<<< HEAD +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +======= +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +>>>>>>> f6ae741 (fix) +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/src/main/java/com/gdscys/cokepoke/CokePokeApplication.java b/src/main/java/com/gdscys/cokepoke/CokePokeApplication.java new file mode 100644 index 0000000..7b2b0f9 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/CokePokeApplication.java @@ -0,0 +1,12 @@ +package com.gdscys.cokepoke; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CokePokeApplication { + public static void main(String[] args) { + SpringApplication.run(CokePokeApplication.class, args); + } + +} diff --git a/src/main/java/com/gdscys/cokepoke/auth/controller/AuthController.java b/src/main/java/com/gdscys/cokepoke/auth/controller/AuthController.java new file mode 100644 index 0000000..2facca7 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/auth/controller/AuthController.java @@ -0,0 +1,52 @@ +package com.gdscys.cokepoke.auth.controller; + +import com.gdscys.cokepoke.auth.domain.TokenInfo; +import com.gdscys.cokepoke.auth.dto.LoginRequest; +import com.gdscys.cokepoke.auth.service.CustomUserDetailsService; +import com.gdscys.cokepoke.member.domain.Member; +import com.gdscys.cokepoke.member.dto.SignupRequest; +import com.gdscys.cokepoke.member.dto.MemberResponse; +import javax.validation.Valid; + +import com.gdscys.cokepoke.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import static org.springframework.http.HttpHeaders.SET_COOKIE; + +@Controller +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + private final MemberService memberService; + private final CustomUserDetailsService userDetailsService; + + @PostMapping("/signup") + public ResponseEntity signup(@RequestBody @Valid SignupRequest request) { + Member member = memberService.saveMember(request.getEmail(), request.getUsername(), request.getPassword()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(MemberResponse.of(member)); + } + + @PostMapping(value = "/login") + public ResponseEntity login(@RequestBody @Valid LoginRequest loginRequest) { + TokenInfo tokenInfo = userDetailsService.login(loginRequest.getEmail(), loginRequest.getPassword()); + return ResponseEntity.ok() + .header(SET_COOKIE, generateCookie("accessToken", tokenInfo.getAccessToken()).toString()) + .header(SET_COOKIE, generateCookie("refreshToken", tokenInfo.getRefreshToken()).toString()) + .body(tokenInfo); + } + + private ResponseCookie generateCookie(String from, String token) { + return ResponseCookie.from(from, token) + .httpOnly(true) // false로 하면 클라이언트도 쿠키로 접근할 수 있기 때문에 보안상 조치 + .path("/") + .build(); + } +} diff --git a/src/main/java/com/gdscys/cokepoke/auth/domain/JwtCode.java b/src/main/java/com/gdscys/cokepoke/auth/domain/JwtCode.java new file mode 100644 index 0000000..36cd105 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/auth/domain/JwtCode.java @@ -0,0 +1,5 @@ +package com.gdscys.cokepoke.auth.domain; + +public enum JwtCode { + ACCESS,EXPIRED,DENIED +} \ No newline at end of file diff --git a/src/main/java/com/gdscys/cokepoke/auth/domain/TokenInfo.java b/src/main/java/com/gdscys/cokepoke/auth/domain/TokenInfo.java new file mode 100644 index 0000000..6183c72 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/auth/domain/TokenInfo.java @@ -0,0 +1,16 @@ +package com.gdscys.cokepoke.auth.domain; + +import lombok.Getter; + +@Getter +public class TokenInfo { + private String accessToken; + private String refreshToken; + + protected TokenInfo() {} + + public TokenInfo(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/com/gdscys/cokepoke/auth/dto/LoginRequest.java b/src/main/java/com/gdscys/cokepoke/auth/dto/LoginRequest.java new file mode 100644 index 0000000..1786810 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/auth/dto/LoginRequest.java @@ -0,0 +1,23 @@ +package com.gdscys.cokepoke.auth.dto; + +import com.gdscys.cokepoke.validation.declaration.ValidEmail; +import lombok.Getter; + +import javax.validation.constraints.NotBlank; + +@Getter +public class LoginRequest { + + @ValidEmail + private String email; + + @NotBlank + private String password; + + protected LoginRequest() {} + + public LoginRequest(String email, String password) { + this.email = email; + this.password = password; + } +} \ No newline at end of file diff --git a/src/main/java/com/gdscys/cokepoke/auth/jwt/JwtAuthFilter.java b/src/main/java/com/gdscys/cokepoke/auth/jwt/JwtAuthFilter.java new file mode 100644 index 0000000..040d5ee --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/auth/jwt/JwtAuthFilter.java @@ -0,0 +1,104 @@ +package com.gdscys.cokepoke.auth.jwt; + + +import com.gdscys.cokepoke.auth.domain.JwtCode; +import com.gdscys.cokepoke.auth.domain.TokenInfo; +import com.gdscys.cokepoke.member.domain.RefreshToken; +import com.gdscys.cokepoke.member.repository.RefreshTokenRepository; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Arrays; +import java.util.Optional; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + Optional accessToken = extractTokenFromCookie(request, "accessToken"); + Optional refreshToken = extractTokenFromCookie(request, "refreshToken"); + + // 유효한 토큰인지 확인합니다. + if (accessToken.isPresent() && jwtTokenProvider.validateToken(accessToken.get()) == JwtCode.ACCESS) { + // 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다. + Authentication authentication = jwtTokenProvider.getAuthentication(accessToken.get()); + // SecurityContext 에 Authentication 객체를 저장합니다. + SecurityContextHolder.getContext().setAuthentication(authentication); + } else if (accessToken.isPresent() && jwtTokenProvider.validateToken(accessToken.get()) == JwtCode.EXPIRED) { + log.info("Access token expired"); + + // refresh token 검증 + if (refreshToken.isPresent() && jwtTokenProvider.validateToken(refreshToken.get()) == JwtCode.ACCESS) { + + Optional savedToken = refreshTokenRepository.findByRefreshToken(refreshToken.get()); + + Claims claims = jwtTokenProvider.parseClaims(accessToken.get()); + + if (savedToken.isPresent() && claims.get("email").equals(savedToken.get().getMember().getEmail())) { + // accessToken 으로 부터 Authentication 객체 추출 + Authentication authentication = jwtTokenProvider.getAuthentication(accessToken.get()); + + // email 을 추출하여 accessToken, refreshToken 생성 + TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication, savedToken.get().getMember().getEmail()); + + // 인증 객체 설정 + SecurityContextHolder.getContext().setAuthentication(authentication); + + // refreshToken 업데이트 + savedToken.get().setRefreshToken(tokenInfo.getRefreshToken()); + refreshTokenRepository.save(savedToken.get()); + + response.addCookie(jwtTokenProvider.generateCookie("refreshToken", tokenInfo.getRefreshToken())); + response.addCookie(jwtTokenProvider.generateCookie("accessToken", tokenInfo.getAccessToken())); + + log.info("Reissue access token"); + } + } + } + + filterChain.doFilter(request, response); + } + + private Optional extractTokenFromCookie(HttpServletRequest request, String cookieName) { + + if (request.getCookies() == null || request.getCookies().length == 0) return Optional.empty(); + + return Arrays.stream(request.getCookies()) + .sequential() + .filter(cookie -> cookie.getName().equals(cookieName)) + .map(Cookie::getValue) + .findFirst(); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // request 에서 요청 path 추출 + String path = request.getServletPath(); + + // filter 에서 제외한 url 목록 + String[] excludedPaths = { "/auth/login", "/auth/signup", "/h2-console"}; + + for (String excludedPath : excludedPaths) { + if (path.startsWith(excludedPath)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/main/java/com/gdscys/cokepoke/auth/jwt/JwtTokenProvider.java b/src/main/java/com/gdscys/cokepoke/auth/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..7b3a2c4 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/auth/jwt/JwtTokenProvider.java @@ -0,0 +1,117 @@ +package com.gdscys.cokepoke.auth.jwt; + +import com.gdscys.cokepoke.auth.domain.JwtCode; +import com.gdscys.cokepoke.auth.domain.TokenInfo; +import io.jsonwebtoken.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import javax.crypto.spec.SecretKeySpec; +import javax.servlet.http.Cookie; +import java.security.Key; +import java.sql.Date; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class JwtTokenProvider { + private final Key key; + + public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) { + byte[] keyBytes = Base64.getDecoder().decode(secretKey.getBytes()); + this.key = new SecretKeySpec(keyBytes, SignatureAlgorithm.HS256.getJcaName()); + } + + public TokenInfo generateToken(Authentication authentication, String email) { + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + LocalDateTime now = LocalDateTime.now(); + + String accessToken = Jwts.builder() + .setSubject(authentication.getName()) + .claim("auth", authorities) + .claim("email", email) + .setExpiration(Date.from(now + .plusMinutes(30) + .atZone(ZoneId.systemDefault()).toInstant())) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + String refreshToken = Jwts.builder() + .setExpiration(Date.from(now + .plusDays(14) + .atZone(ZoneId.systemDefault()).toInstant())) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + return new TokenInfo(accessToken, refreshToken); + } + + // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드 + public Authentication getAuthentication(String accessToken) { + // 토큰 복호화 + Claims claims = parseClaims(accessToken); + + if (claims.get("auth") == null) { + throw new AccessDeniedException("권한 정보가 없는 토큰입니다."); + } + + // 클레임에서 권한 정보 가져오기 + Collection authorities = + Arrays.stream(claims.get("auth").toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + // UserDetails 객체를 만들어서 Authentication 리턴 + UserDetails principal = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } + + + public JwtCode validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return JwtCode.ACCESS; + } catch (ExpiredJwtException e) { + // 기한 만료 + return JwtCode.EXPIRED; + } catch (Exception e) { + // parsing 에러 + return JwtCode.DENIED; + } + } + + public Claims parseClaims(String token) { + try { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + + public Cookie generateCookie(String from, String token) { + Cookie cookie = new Cookie(from, token); + + cookie.setPath("/"); + cookie.setHttpOnly(true); // XSS 공격을 막기 위한 설정 + cookie.setSecure(true); + + return cookie; + } + +} \ No newline at end of file diff --git a/src/main/java/com/gdscys/cokepoke/auth/service/CustomUserDetailsService.java b/src/main/java/com/gdscys/cokepoke/auth/service/CustomUserDetailsService.java new file mode 100644 index 0000000..2aa12d4 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/auth/service/CustomUserDetailsService.java @@ -0,0 +1,72 @@ +package com.gdscys.cokepoke.auth.service; + +import com.gdscys.cokepoke.auth.domain.TokenInfo; +import com.gdscys.cokepoke.auth.jwt.JwtTokenProvider; +import com.gdscys.cokepoke.member.domain.Member; +import com.gdscys.cokepoke.member.domain.RefreshToken; +import com.gdscys.cokepoke.member.repository.MemberRepository; +import com.gdscys.cokepoke.member.repository.RefreshTokenRepository; +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.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + return memberRepository.findByEmail(email) + .map(member -> User.builder() + .username(member.getUsername()) + .password(member.getPasswordHash()) + .roles(Arrays.toString(member.getRoles().toArray())) + .build()) + .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); + } + + @Transactional + public TokenInfo login(String email, String password) { + + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(email, password); + + Authentication authentication = authenticationManagerBuilder + .getObject().authenticate(authenticationToken); + Optional member = memberRepository.findByEmail(email); + + if (member.isEmpty()) { + throw new UsernameNotFoundException("User not found with email: " + email); + } + + //토큰 생성 + TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication, email); + + // refresh token 없으면 생성, 있으면 업데이트 + refreshTokenRepository.findByMember_Email(member.get().getEmail()) + .ifPresentOrElse(refreshToken -> { + refreshToken.setRefreshToken(tokenInfo.getRefreshToken()); + refreshTokenRepository.save(refreshToken); + }, () -> refreshTokenRepository.save(new RefreshToken(tokenInfo.getRefreshToken(), member.get()))); + return tokenInfo; + } + + +} diff --git a/src/main/java/com/gdscys/cokepoke/auth/util/DelegatingSecurityContextRepository.java b/src/main/java/com/gdscys/cokepoke/auth/util/DelegatingSecurityContextRepository.java new file mode 100644 index 0000000..8ea0957 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/auth/util/DelegatingSecurityContextRepository.java @@ -0,0 +1,53 @@ +package com.gdscys.cokepoke.auth.util; + +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.web.context.HttpRequestResponseHolder; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.util.Assert; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Arrays; +import java.util.List; + +public class DelegatingSecurityContextRepository implements SecurityContextRepository { + private final List delegates; + + public DelegatingSecurityContextRepository(SecurityContextRepository... delegates) { + this(Arrays.asList(delegates)); + } + + public DelegatingSecurityContextRepository(List delegates) { + Assert.notEmpty(delegates, "delegates cannot be empty"); + this.delegates = delegates; + } + + @Override + public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) { + SecurityContext result = null; + for (SecurityContextRepository delegate : this.delegates) { + SecurityContext delegateResult = delegate.loadContext(requestResponseHolder); + if (result == null || delegate.containsContext(requestResponseHolder.getRequest())) { + result = delegateResult; + } + } + return result; + } + + @Override + public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) { + for (SecurityContextRepository delegate : this.delegates) { + delegate.saveContext(context, request, response); + } + } + + @Override + public boolean containsContext(HttpServletRequest request) { + for (SecurityContextRepository delegate : this.delegates) { + if (delegate.containsContext(request)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/gdscys/cokepoke/auth/util/SecurityUtil.java b/src/main/java/com/gdscys/cokepoke/auth/util/SecurityUtil.java new file mode 100644 index 0000000..20f2006 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/auth/util/SecurityUtil.java @@ -0,0 +1,12 @@ +package com.gdscys.cokepoke.auth.util; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public class SecurityUtil { + public static String getLoginUsername(){ + Authentication loggedInUser = SecurityContextHolder.getContext().getAuthentication(); + String username = loggedInUser.getName(); + return username; + } +} diff --git a/src/main/java/com/gdscys/cokepoke/configuration/QueryDslConfig.java b/src/main/java/com/gdscys/cokepoke/configuration/QueryDslConfig.java new file mode 100644 index 0000000..4a43933 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/configuration/QueryDslConfig.java @@ -0,0 +1,21 @@ +package com.gdscys.cokepoke.configuration; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +@EnableJpaAuditing +@Configuration +public class QueryDslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/gdscys/cokepoke/configuration/SecurityConfig.java b/src/main/java/com/gdscys/cokepoke/configuration/SecurityConfig.java new file mode 100644 index 0000000..20eb766 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/configuration/SecurityConfig.java @@ -0,0 +1,67 @@ +package com.gdscys.cokepoke.configuration; + +import com.gdscys.cokepoke.auth.util.DelegatingSecurityContextRepository; +import com.gdscys.cokepoke.auth.jwt.JwtAuthFilter; +import com.gdscys.cokepoke.auth.jwt.JwtTokenProvider; +import com.gdscys.cokepoke.member.repository.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.configuration.WebSecurityCustomizer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + // 정적 자원에 대해서 Security를 적용하지 않음으로 설정 + return web -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations()); + } + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.httpBasic().disable() + .csrf().disable() + .formLogin().disable() + .headers().frameOptions().sameOrigin() + .and() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() + .authorizeRequests() + .antMatchers("/", "/auth/**", "/auth/login", "/auth/signup", + "/h2-console/**") + .permitAll() + .anyRequest().authenticated() + .and() + .securityContext((securityContext) -> securityContext + .securityContextRepository(new DelegatingSecurityContextRepository( + new RequestAttributeSecurityContextRepository(), + new HttpSessionSecurityContextRepository() + )) + ) + .addFilterBefore(new JwtAuthFilter(jwtTokenProvider, refreshTokenRepository), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + + + + } + +} \ No newline at end of file diff --git a/src/main/java/com/gdscys/cokepoke/configuration/SwaggerConfig.java b/src/main/java/com/gdscys/cokepoke/configuration/SwaggerConfig.java new file mode 100644 index 0000000..6a979b4 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/configuration/SwaggerConfig.java @@ -0,0 +1,24 @@ +package com.gdscys.cokepoke.configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .components(new Components()) + .info(apiInfo()); + } + + private Info apiInfo() { + return new Info() + .title("Springdoc 테스트") + .description("Springdoc을 사용한 Swagger UI 테스트") + .version("1.0.0"); + } +} diff --git a/src/main/java/com/gdscys/cokepoke/exception/ErrorResponse.java b/src/main/java/com/gdscys/cokepoke/exception/ErrorResponse.java new file mode 100644 index 0000000..d2c7145 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/exception/ErrorResponse.java @@ -0,0 +1,41 @@ +package com.gdscys.cokepoke.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Getter +public class ErrorResponse { + private final Exception exception; + private final String message; + private final HttpStatus status; + private List errors; + + private ErrorResponse(Exception exception, String message, HttpStatus status) { + this.exception = exception; + this.message = message; + this.status = status; + } + + public static ErrorResponse of(Exception exception, String message, HttpStatus status) { + return new ErrorResponse(exception, message, status); + } + + @Getter + @RequiredArgsConstructor + private static class ValidationError { + private final String field; + private final String message; + } + + public void addValidationError(String field, String message){ + if(Objects.isNull(errors)){ + errors = new ArrayList<>(); + } + errors.add(new ValidationError(field, message)); + } +} diff --git a/src/main/java/com/gdscys/cokepoke/exception/GlobalExceptionHandler.java b/src/main/java/com/gdscys/cokepoke/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..87b764a --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/exception/GlobalExceptionHandler.java @@ -0,0 +1,90 @@ +package com.gdscys.cokepoke.exception; + + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.context.request.WebRequest; + +import java.io.IOException; +import java.nio.file.AccessDeniedException; +import java.util.NoSuchElementException; + +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ResponseEntity handleAccessDeniedException( + AccessDeniedException exception, + WebRequest request + ) { + ErrorResponse response = ErrorResponse.of( + exception, + exception.getMessage(), + HttpStatus.FORBIDDEN + ); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } + + @ExceptionHandler(NoSuchElementException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ResponseEntity handleNoSuchElementFoundException( + NoSuchElementException exception, WebRequest request + ) { + ErrorResponse response = ErrorResponse.of( + exception, + exception.getMessage(), + HttpStatus.NOT_FOUND + ); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + public ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + WebRequest request + ) { + ErrorResponse errorResponse = ErrorResponse.of( + ex, + "Validation error. Check 'errors' field for details.", + HttpStatus.UNPROCESSABLE_ENTITY + ); + + for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { + errorResponse.addValidationError(fieldError.getField(), + fieldError.getDefaultMessage()); + } + return ResponseEntity.unprocessableEntity().body(errorResponse); + } + + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseEntity handleIllegalArgumentException( + IllegalArgumentException exception, + WebRequest request){ + ErrorResponse response = ErrorResponse.of(exception, + exception.getMessage(), + HttpStatus.BAD_REQUEST + ); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ResponseEntity handleAllUncaughtException( + Exception exception, + WebRequest request){ + ErrorResponse response = ErrorResponse.of(exception, + exception.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR + ); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + +} diff --git a/src/main/java/com/gdscys/cokepoke/member/controller/MemberController.java b/src/main/java/com/gdscys/cokepoke/member/controller/MemberController.java new file mode 100644 index 0000000..6071bfc --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/member/controller/MemberController.java @@ -0,0 +1,30 @@ +package com.gdscys.cokepoke.member.controller; + +import com.gdscys.cokepoke.member.domain.Member; +import com.gdscys.cokepoke.member.dto.MemberResponse; +import com.gdscys.cokepoke.member.service.MemberService; +import javax.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; +import java.util.stream.Collectors; + +@Controller +@RequestMapping("/member") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + @GetMapping("/my") + public ResponseEntity viewMy(@AuthenticationPrincipal Member member) { + return ResponseEntity.ok(MemberResponse.of(member)); + } + +} diff --git a/src/main/java/com/gdscys/cokepoke/member/domain/Member.java b/src/main/java/com/gdscys/cokepoke/member/domain/Member.java new file mode 100644 index 0000000..bad2e74 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/member/domain/Member.java @@ -0,0 +1,108 @@ +package com.gdscys.cokepoke.member.domain; + +import com.gdscys.cokepoke.validation.declaration.ValidEmail; +import javax.persistence.*; +import lombok.Getter; +import net.minidev.json.annotate.JsonIgnore; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.GenericGenerator; +import org.hibernate.annotations.UpdateTimestamp; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Entity(name = "Member") +@Getter +@EntityListeners(AuditingEntityListener.class) +@Table(indexes = { + @Index(name = "idx_member_email", columnList = "email", unique = true) +}) +public class Member implements UserDetails { + + @Id @Column(name = "id", updatable = false, nullable = false, unique = true) + @GeneratedValue(generator = "uuid2") + @GenericGenerator(name = "uuid2", strategy = "uuid2") + private UUID id; + + @Column(name = "email", unique = true, nullable = false, columnDefinition = "varchar(40)") + @ValidEmail + private String email; + + @Column(name = "username", nullable = false, columnDefinition = "varchar(20)") + private String username; + + @JsonIgnore + @Column(name = "password_hash", nullable = false) + private String passwordHash; + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + @ElementCollection(fetch = FetchType.EAGER) + private Set roles = new HashSet<>(); + + @Column(name = "is_expired", nullable = false) + private boolean isExpired = false; + + + protected Member() {} + + public Member(String email, String username, String passwordHash, Set roles) { + this.email = email; + this.username = username; + this.passwordHash = passwordHash; + this.roles = roles; + } + + public void updatePassword(String passwordHash) { + this.passwordHash = passwordHash; + } + + @Override + public Collection getAuthorities() { + return this.roles.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toSet()); + } + + @Override + public String getPassword() { + return this.passwordHash; + } + + @Override + public String getUsername() { + return this.username; + } + + @Override + public boolean isAccountNonExpired() { + return !this.isExpired; + } + + @Override + public boolean isAccountNonLocked() { + return !this.isExpired; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/gdscys/cokepoke/member/domain/RefreshToken.java b/src/main/java/com/gdscys/cokepoke/member/domain/RefreshToken.java new file mode 100644 index 0000000..6a89bf2 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/member/domain/RefreshToken.java @@ -0,0 +1,32 @@ +package com.gdscys.cokepoke.member.domain; + +import javax.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.GenericGenerator; + +import java.util.UUID; + +@Entity +@Getter +@Setter +public class RefreshToken { + + @Id + @GeneratedValue(generator = "uuid2") + @GenericGenerator(name = "uuid2", strategy = "uuid2") + private UUID id; + + private String refreshToken; + + @OneToOne(cascade = CascadeType.REMOVE, fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", unique = true) + private Member member; + + public RefreshToken(String refreshToken, Member member) { + this.refreshToken = refreshToken; + this.member = member; + } + + protected RefreshToken() {} +} diff --git a/src/main/java/com/gdscys/cokepoke/member/dto/MemberResponse.java b/src/main/java/com/gdscys/cokepoke/member/dto/MemberResponse.java new file mode 100644 index 0000000..4ddae22 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/member/dto/MemberResponse.java @@ -0,0 +1,26 @@ +package com.gdscys.cokepoke.member.dto; + +import com.gdscys.cokepoke.member.domain.Member; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class MemberResponse { + private String email; + private String username; + + protected MemberResponse() {} + + public MemberResponse(String email, String username) { + this.email = email; + this.username = username; + } + + public static MemberResponse of(Member member) { + return MemberResponse.builder() + .email(member.getEmail()) + .username(member.getUsername()) + .build(); + } +} diff --git a/src/main/java/com/gdscys/cokepoke/member/dto/SignupRequest.java b/src/main/java/com/gdscys/cokepoke/member/dto/SignupRequest.java new file mode 100644 index 0000000..5093db7 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/member/dto/SignupRequest.java @@ -0,0 +1,38 @@ +package com.gdscys.cokepoke.member.dto; + +import com.gdscys.cokepoke.validation.declaration.PasswordMatches; +import com.gdscys.cokepoke.validation.declaration.ValidEmail; +import javax.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; +import org.hibernate.validator.constraints.Length; + +@Getter +@PasswordMatches +public class SignupRequest { + + @NotBlank + @ValidEmail + private String email; + + @NotBlank + private String username; + + @NotBlank + @Length(min = 8, max = 20) + private String password; + + @NotBlank + @Length(min = 8, max = 20) + private String confirmPassword; + + protected SignupRequest() {} + + @Builder + public SignupRequest(String email, String username, String password, String confirmPassword) { + this.email = email; + this.username = username; + this.password = password; + this.confirmPassword = confirmPassword; + } +} diff --git a/src/main/java/com/gdscys/cokepoke/member/dto/UpdateMemberRequest.java b/src/main/java/com/gdscys/cokepoke/member/dto/UpdateMemberRequest.java new file mode 100644 index 0000000..b281cd1 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/member/dto/UpdateMemberRequest.java @@ -0,0 +1,22 @@ +package com.gdscys.cokepoke.member.dto; + +import com.gdscys.cokepoke.validation.declaration.PasswordMatches; +import lombok.Builder; +import lombok.Getter; + +@Getter +@PasswordMatches +public class UpdateMemberRequest { + private String originalPassword; + private String newPassword; + private String confirmNewPassword; + + protected UpdateMemberRequest() {} + + @Builder + public UpdateMemberRequest(String originalPassword, String newPassword, String confirmNewPassword) { + this.originalPassword = originalPassword; + this.newPassword = newPassword; + this.confirmNewPassword = confirmNewPassword; + } +} diff --git a/src/main/java/com/gdscys/cokepoke/member/repository/MemberRepository.java b/src/main/java/com/gdscys/cokepoke/member/repository/MemberRepository.java new file mode 100644 index 0000000..44f4a87 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/member/repository/MemberRepository.java @@ -0,0 +1,17 @@ +package com.gdscys.cokepoke.member.repository; + +import com.gdscys.cokepoke.member.domain.Member; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); + Optional findByUsername(String username); +} diff --git a/src/main/java/com/gdscys/cokepoke/member/repository/RefreshTokenRepository.java b/src/main/java/com/gdscys/cokepoke/member/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..453a724 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/member/repository/RefreshTokenRepository.java @@ -0,0 +1,14 @@ +package com.gdscys.cokepoke.member.repository; + +import com.gdscys.cokepoke.member.domain.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + Optional findByMember_Email(String email); + Optional findByRefreshToken(String refreshToken); +} diff --git a/src/main/java/com/gdscys/cokepoke/member/service/IMemberService.java b/src/main/java/com/gdscys/cokepoke/member/service/IMemberService.java new file mode 100644 index 0000000..a5a7070 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/member/service/IMemberService.java @@ -0,0 +1,13 @@ +package com.gdscys.cokepoke.member.service; + +import com.gdscys.cokepoke.member.domain.Member; +import com.gdscys.cokepoke.member.dto.UpdateMemberRequest; + +import java.util.List; + +public interface IMemberService { + Member saveMember(String email, String username, String password); + Member findMemberByUsername(String username); + void updateMember(Member member, UpdateMemberRequest request); + void deleteMember(Member member); +} diff --git a/src/main/java/com/gdscys/cokepoke/member/service/MemberService.java b/src/main/java/com/gdscys/cokepoke/member/service/MemberService.java new file mode 100644 index 0000000..f2687ac --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/member/service/MemberService.java @@ -0,0 +1,58 @@ +package com.gdscys.cokepoke.member.service; + +import com.gdscys.cokepoke.auth.jwt.JwtTokenProvider; +import com.gdscys.cokepoke.auth.domain.TokenInfo; +import com.gdscys.cokepoke.member.repository.RefreshTokenRepository; +import com.gdscys.cokepoke.member.domain.Member; + +import com.gdscys.cokepoke.member.domain.RefreshToken; +import com.gdscys.cokepoke.member.dto.UpdateMemberRequest; +import com.gdscys.cokepoke.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +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.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + + +@Service +@RequiredArgsConstructor +public class MemberService implements IMemberService { + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + private final static int PAGE_SIZE = 15; + + @Override + public Member saveMember(String email, String username, String password) { + Member member = new Member(email, username, passwordEncoder.encode(password), Set.of("USER")); + return memberRepository.save(member); + } + + @Override + public Member findMemberByUsername(String username) { + return memberRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username)); + } + + @Override + public void updateMember(Member member, UpdateMemberRequest request) { + member.updatePassword(passwordEncoder.encode(request.getNewPassword())); + } + + @Override + public void deleteMember(Member member) { + memberRepository.delete(member); + } + +} diff --git a/src/main/java/com/gdscys/cokepoke/validation/declaration/PasswordMatches.java b/src/main/java/com/gdscys/cokepoke/validation/declaration/PasswordMatches.java new file mode 100644 index 0000000..4ba4388 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/validation/declaration/PasswordMatches.java @@ -0,0 +1,22 @@ +package com.gdscys.cokepoke.validation.declaration; + +import com.gdscys.cokepoke.validation.implementation.PasswordMatchesValidator; +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Target({TYPE,ANNOTATION_TYPE}) +@Retention(RUNTIME) +@Constraint(validatedBy = PasswordMatchesValidator.class) +@Documented +public @interface PasswordMatches { + String message() default "Your passwords do not match."; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/com/gdscys/cokepoke/validation/declaration/ValidEmail.java b/src/main/java/com/gdscys/cokepoke/validation/declaration/ValidEmail.java new file mode 100644 index 0000000..f5d7081 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/validation/declaration/ValidEmail.java @@ -0,0 +1,19 @@ +package com.gdscys.cokepoke.validation.declaration; + +import com.gdscys.cokepoke.validation.implementation.EmailValidator; +import javax.validation.Constraint; +import javax.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.FIELD, ElementType.ANNOTATION_TYPE}) +@Retention(java.lang.annotation.RetentionPolicy.RUNTIME) +@Constraint(validatedBy = {EmailValidator.class}) +public @interface ValidEmail { + + String message() default "Invalid email"; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/com/gdscys/cokepoke/validation/implementation/EmailValidator.java b/src/main/java/com/gdscys/cokepoke/validation/implementation/EmailValidator.java new file mode 100644 index 0000000..80136a4 --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/validation/implementation/EmailValidator.java @@ -0,0 +1,29 @@ +package com.gdscys.cokepoke.validation.implementation; + +import com.gdscys.cokepoke.validation.declaration.ValidEmail; +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class EmailValidator implements ConstraintValidator { + + private Pattern pattern; + private Matcher matcher; + private static final String EMAIL_PATTERN = "^[_A-Za-z0-9-+]+(.[_A-Za-z0-9-]+)*@" + "[A-Za-z0-9-]+(.[A-Za-z0-9]+)*(.[A-Za-z]{2,})$"; + + @Override + public void initialize(ValidEmail constraintAnnotation) {} + + @Override + public boolean isValid(String email, ConstraintValidatorContext context) { + return validateEmail(email); + } + + private boolean validateEmail(String email) { + pattern = Pattern.compile(EMAIL_PATTERN); + matcher = pattern.matcher(email); + return matcher.matches(); + } +} diff --git a/src/main/java/com/gdscys/cokepoke/validation/implementation/PasswordMatchesValidator.java b/src/main/java/com/gdscys/cokepoke/validation/implementation/PasswordMatchesValidator.java new file mode 100644 index 0000000..1526e4f --- /dev/null +++ b/src/main/java/com/gdscys/cokepoke/validation/implementation/PasswordMatchesValidator.java @@ -0,0 +1,31 @@ +package com.gdscys.cokepoke.validation.implementation; + +import com.gdscys.cokepoke.member.dto.SignupRequest; +import com.gdscys.cokepoke.member.dto.UpdateMemberRequest; +import com.gdscys.cokepoke.validation.declaration.PasswordMatches; +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class PasswordMatchesValidator implements ConstraintValidator { + + @Override + public boolean isValid(Object obj, ConstraintValidatorContext context){ + if (obj.getClass().equals(SignupRequest.class)) { + return validateSignupRequest(obj); + } + if (obj.getClass().equals(UpdateMemberRequest.class)) { + return validateUpdateUserRequest(obj); + } + return false; + } + + public boolean validateSignupRequest(Object obj) { + SignupRequest request = (SignupRequest) obj; + return request.getPassword().equals(request.getConfirmPassword()); + } + public boolean validateUpdateUserRequest(Object obj) { + UpdateMemberRequest request = (UpdateMemberRequest) obj; + return ((!request.getOriginalPassword().equals(request.getNewPassword())) + && request.getNewPassword().equals(request.getConfirmNewPassword())); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..20cf506 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,24 @@ +server: + port: 8080 +spring: + datasource: + url: jdbc:h2:tcp://localhost/~/testdb + username: sa + password: password + driver-class-name: org.h2.Driver + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create + h2: + console: + enabled: true + path: /h2-console +jwt: + secret: fpvuvttzKZKGmQ3UoD0QpU7oSDAmCKb8X6l78RULm1Rm01R5Itcg5C8Q9me5sffF + +logging: + level: + org: + springframework: + security=DEBUG: DEBUG \ No newline at end of file diff --git a/src/test/java/com/gdscys/cokepoke/CokePokeApplicationTests.java b/src/test/java/com/gdscys/cokepoke/CokePokeApplicationTests.java new file mode 100644 index 0000000..d2765f9 --- /dev/null +++ b/src/test/java/com/gdscys/cokepoke/CokePokeApplicationTests.java @@ -0,0 +1,13 @@ +package com.gdscys.cokepoke; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class CokePokeApplicationTests { + + @Test + void contextLoads() { + } + +}