diff --git a/module-application/build.gradle b/module-application/build.gradle index 86ecc015..bafd6d1f 100644 --- a/module-application/build.gradle +++ b/module-application/build.gradle @@ -14,4 +14,14 @@ dependencies { // Bean Validation implementation 'org.springframework.boot:spring-boot-starter-validation' + + // JPA + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' } diff --git a/module-application/src/main/java/com/devtoon/jtoon/member/presentation/MemberController.java b/module-application/src/main/java/com/devtoon/jtoon/member/presentation/MemberController.java index ee90f68b..610b7167 100644 --- a/module-application/src/main/java/com/devtoon/jtoon/member/presentation/MemberController.java +++ b/module-application/src/main/java/com/devtoon/jtoon/member/presentation/MemberController.java @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.RestController; import com.devtoon.jtoon.member.application.MemberService; -import com.devtoon.jtoon.member.request.SignUpDto; +import com.devtoon.jtoon.member.request.SignUpReq; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -23,8 +23,8 @@ public class MemberController { @PostMapping @ResponseStatus(HttpStatus.CREATED) - public void signUp(@RequestBody @Valid SignUpDto signUpDto) { - memberService.createMember(signUpDto); + public void signUp(@RequestBody @Valid SignUpReq signUpReq) { + memberService.createMember(signUpReq); } @GetMapping("/email-authorization") diff --git a/module-application/src/main/java/com/devtoon/jtoon/security/application/AuthService.java b/module-application/src/main/java/com/devtoon/jtoon/security/application/AuthService.java new file mode 100644 index 00000000..a9782333 --- /dev/null +++ b/module-application/src/main/java/com/devtoon/jtoon/security/application/AuthService.java @@ -0,0 +1,40 @@ +package com.devtoon.jtoon.security.application; + +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.devtoon.jtoon.member.entity.Member; +import com.devtoon.jtoon.member.repository.MemberRepository; +import com.devtoon.jtoon.security.jwt.JwtProvider; +import com.devtoon.jtoon.security.request.LogInReq; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; + + @Transactional + public String login(LogInReq logInReq) { + Member member = memberRepository.findByEmail(logInReq.email()) + .orElseThrow(() -> new BadCredentialsException("너 안돼!")); + + if (!isPasswordSame(logInReq.password(), member.getPassword())) { + throw new BadCredentialsException("너 안돼!"); + } + + member.updateLastLogin(); + + return jwtProvider.generateToken(logInReq.email()); + } + + public boolean isPasswordSame(String rawPassword, String memberPassword) { + return passwordEncoder.matches(rawPassword, memberPassword); + } +} diff --git a/module-application/src/main/java/com/devtoon/jtoon/security/config/WebSecurityConfiguration.java b/module-application/src/main/java/com/devtoon/jtoon/security/config/WebSecurityConfiguration.java new file mode 100644 index 00000000..a876469b --- /dev/null +++ b/module-application/src/main/java/com/devtoon/jtoon/security/config/WebSecurityConfiguration.java @@ -0,0 +1,47 @@ +package com.devtoon.jtoon.security.config; + +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import com.devtoon.jtoon.security.filter.JwtAuthenticationFilter; +import com.devtoon.jtoon.security.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +@EnableWebSecurity +public class WebSecurityConfiguration { + + private final HandlerExceptionResolver handlerExceptionResolver; + private final JwtProvider jwtProvider; + + @Bean + public PasswordEncoder encoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain (HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(request -> request + .requestMatchers("/members").permitAll() + .requestMatchers("/members/email-authorization").permitAll() + .requestMatchers("/members/**").hasAuthority("USER") + .anyRequest().permitAll()) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(new JwtAuthenticationFilter(handlerExceptionResolver, jwtProvider), UsernamePasswordAuthenticationFilter.class) + ; + return http.build(); + } +} diff --git a/module-application/src/main/java/com/devtoon/jtoon/security/filter/JwtAuthenticationFilter.java b/module-application/src/main/java/com/devtoon/jtoon/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..196fc379 --- /dev/null +++ b/module-application/src/main/java/com/devtoon/jtoon/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,48 @@ +package com.devtoon.jtoon.security.filter; + +import org.jetbrains.annotations.NotNull; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import com.devtoon.jtoon.security.jwt.JwtProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final HandlerExceptionResolver handlerExceptionResolver; + private final JwtProvider jwtProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + @NotNull HttpServletResponse response, + @NotNull FilterChain filterChain + ) throws ServletException, IOException { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (header != null && header.startsWith("Bearer")) { + try { + String token = header.split(" ")[1]; + jwtProvider.validateToken(token); + Authentication auth = jwtProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (RuntimeException e) { + log.error("Invalid Token", e); + handlerExceptionResolver.resolveException(request, response, null, e); + return; + } + } + filterChain.doFilter(request, response); + } +} diff --git a/module-application/src/main/java/com/devtoon/jtoon/security/jwt/JwtProvider.java b/module-application/src/main/java/com/devtoon/jtoon/security/jwt/JwtProvider.java new file mode 100644 index 00000000..3db46c33 --- /dev/null +++ b/module-application/src/main/java/com/devtoon/jtoon/security/jwt/JwtProvider.java @@ -0,0 +1,99 @@ +package com.devtoon.jtoon.security.jwt; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtProvider { + + @Value("${jwt.secret.key}") + private String SALT; + + @Value("${jwt.iss}") + private String ISS; + + @Value("${jwt.expire}") + private long EXPIRE; + + private Key secretKey; + + private final UserDetailsService userDetailsService; + + @PostConstruct + private void init() { + secretKey = Keys.hmacShaKeyFor(SALT.getBytes(StandardCharsets.UTF_8)); + } + + public String generateToken(String email) { + Claims claims = getClaims(email); + + return Jwts.builder() + .setClaims(claims) + .signWith(secretKey, SignatureAlgorithm.HS256) + .setHeader(getHeaders()) + .compact(); + } + + private Map getHeaders() { + Map headers = new HashMap<>(); + headers.put("alg", "HS256"); + headers.put("typ", "JWT"); + + return headers; + } + + public void validateToken(String token) { + Date claimsExpiration = parseClaimsBody(token).getExpiration(); + + if (claimsExpiration.before(new Date())) { + throw new RuntimeException("Token Expired"); + } + } + + public Authentication getAuthentication(String token) { + String ClaimsEmail = parseClaimsBody(token).getSubject(); + UserDetails userDetails = userDetailsService.loadUserByUsername(ClaimsEmail); + + return new UsernamePasswordAuthenticationToken(userDetails, " ", userDetails.getAuthorities()); + } + + private Claims parseClaimsBody(String token) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build().parseClaimsJws(token).getBody(); + } + + private Claims getClaims(String email) { + Date now = new Date(); + + return Jwts.claims() + .setSubject(email) + .setIssuer(ISS) + .setExpiration(getExpireTime(now)) + .setIssuedAt(now); + } + + private Date getExpireTime(Date now) { + return new Date(now.getTime() + 1000 * 60 * EXPIRE); + } +} + diff --git a/module-application/src/main/java/com/devtoon/jtoon/security/jwt/application/CustomUserDetailsService.java b/module-application/src/main/java/com/devtoon/jtoon/security/jwt/application/CustomUserDetailsService.java new file mode 100644 index 00000000..15738d49 --- /dev/null +++ b/module-application/src/main/java/com/devtoon/jtoon/security/jwt/application/CustomUserDetailsService.java @@ -0,0 +1,26 @@ +package com.devtoon.jtoon.security.jwt.application; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.devtoon.jtoon.member.entity.Member; +import com.devtoon.jtoon.member.repository.MemberRepository; +import com.devtoon.jtoon.security.jwt.domain.CustomUserDetails; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("Invalid Email")); + + return new CustomUserDetails(member); + } +} diff --git a/module-application/src/main/java/com/devtoon/jtoon/security/jwt/domain/CustomUserDetails.java b/module-application/src/main/java/com/devtoon/jtoon/security/jwt/domain/CustomUserDetails.java new file mode 100644 index 00000000..f5b66116 --- /dev/null +++ b/module-application/src/main/java/com/devtoon/jtoon/security/jwt/domain/CustomUserDetails.java @@ -0,0 +1,51 @@ +package com.devtoon.jtoon.security.jwt.domain; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import com.devtoon.jtoon.member.entity.Member; +import java.util.Collection; +import java.util.List; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final Member member; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(member.getRole().toString())); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getEmail(); + } + + @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/module-application/src/main/java/com/devtoon/jtoon/security/presentation/AuthController.java b/module-application/src/main/java/com/devtoon/jtoon/security/presentation/AuthController.java new file mode 100644 index 00000000..b806d4bf --- /dev/null +++ b/module-application/src/main/java/com/devtoon/jtoon/security/presentation/AuthController.java @@ -0,0 +1,25 @@ +package com.devtoon.jtoon.security.presentation; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import com.devtoon.jtoon.security.application.AuthService; +import com.devtoon.jtoon.security.request.LogInReq; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login") + public void login(@RequestBody @Valid LogInReq logInReq, HttpServletResponse response) { + String token = authService.login(logInReq); + + response.setHeader("Set-Cookie", "Bearer " + token); + } +} diff --git a/module-application/src/main/java/com/devtoon/jtoon/security/request/LogInReq.java b/module-application/src/main/java/com/devtoon/jtoon/security/request/LogInReq.java new file mode 100644 index 00000000..6c598e72 --- /dev/null +++ b/module-application/src/main/java/com/devtoon/jtoon/security/request/LogInReq.java @@ -0,0 +1,13 @@ +package com.devtoon.jtoon.security.request; + +import static com.devtoon.jtoon.global.util.RegExp.*; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record LogInReq( + @Pattern(regexp = EMAIL_PATTERN) String email, + @NotBlank String password +) { + +} diff --git a/module-application/src/main/resources/application.yml b/module-application/src/main/resources/application.yml index 92374faf..0bf72a40 100644 --- a/module-application/src/main/resources/application.yml +++ b/module-application/src/main/resources/application.yml @@ -1,3 +1,3 @@ spring: profiles: - include: s3, smtp + include: s3, smtp, iamport, jwt diff --git a/module-domain-smtp/src/main/java/com/devtoon/jtoon/smtp/entity/Mail.java b/module-domain-smtp/src/main/java/com/devtoon/jtoon/smtp/entity/Mail.java index be9d42da..5a69b1af 100644 --- a/module-domain-smtp/src/main/java/com/devtoon/jtoon/smtp/entity/Mail.java +++ b/module-domain-smtp/src/main/java/com/devtoon/jtoon/smtp/entity/Mail.java @@ -19,7 +19,7 @@ private Mail(String subject, String to, String text) { this.text = text; } - public static Mail createEvent(String subject, String to, String text) { + public static Mail forEvent(String subject, String to, String text) { return Mail.builder() .subject(subject) .to(to) @@ -27,7 +27,7 @@ public static Mail createEvent(String subject, String to, String text) { .build(); } - public static Mail createAuthentication(String to, String text) { + public static Mail forAuthentication(String to, String text) { return Mail.builder() .to(to) .text(text) diff --git a/module-domain/build.gradle b/module-domain/build.gradle index fb095f8a..6471bdd9 100644 --- a/module-domain/build.gradle +++ b/module-domain/build.gradle @@ -5,7 +5,6 @@ repositories { dependencies { // JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation project(path: ':module-domain-smtp') // I-AM-PORT implementation 'com.github.iamport:iamport-rest-client-java:0.2.23' diff --git a/module-domain/src/main/java/com/devtoon/jtoon/member/application/MemberService.java b/module-domain/src/main/java/com/devtoon/jtoon/member/application/MemberService.java index 87d2a3b2..801bdf8f 100644 --- a/module-domain/src/main/java/com/devtoon/jtoon/member/application/MemberService.java +++ b/module-domain/src/main/java/com/devtoon/jtoon/member/application/MemberService.java @@ -8,7 +8,7 @@ import com.devtoon.jtoon.exception.MemberException; import com.devtoon.jtoon.member.entity.Member; import com.devtoon.jtoon.member.repository.MemberRepository; -import com.devtoon.jtoon.member.request.SignUpDto; +import com.devtoon.jtoon.member.request.SignUpReq; import com.devtoon.jtoon.smtp.application.SmtpService; import com.devtoon.jtoon.smtp.entity.Mail; import java.util.UUID; @@ -24,10 +24,11 @@ public class MemberService { private final PasswordEncoder passwordEncoder; @Transactional - public void createMember(SignUpDto signUpDto) { - validateDuplicateEmail(signUpDto.email()); - String encryptedPassword = encodePassword(signUpDto.password()); - Member member = signUpDto.toEntity(encryptedPassword); + public void createMember(SignUpReq signUpReq) { + validateDuplicateEmail(signUpReq.email()); + String encryptedPassword = encodePassword(signUpReq.password()); + Member member = signUpReq.toEntity(encryptedPassword); + memberRepository.save(member); } @@ -35,7 +36,7 @@ public String sendEmailAuthentication(String email) { validateDuplicateEmail(email); UUID uuid = UUID.randomUUID(); String randomUuid = uuid.toString().substring(0, 6); - Mail mail = Mail.createAuthentication(email, randomUuid); + Mail mail = Mail.forAuthentication(email, randomUuid); smtpService.sendMail(mail); return randomUuid; diff --git a/module-domain/src/main/java/com/devtoon/jtoon/member/entity/Member.java b/module-domain/src/main/java/com/devtoon/jtoon/member/entity/Member.java index 4abee474..691ae3af 100644 --- a/module-domain/src/main/java/com/devtoon/jtoon/member/entity/Member.java +++ b/module-domain/src/main/java/com/devtoon/jtoon/member/entity/Member.java @@ -75,4 +75,8 @@ private Member(String email, String password, String name, String nickname, Gend this.role = role; this.loginType = loginType; } + + public void updateLastLogin( ) { + lastLoginDate = LocalDateTime.now(); + } } diff --git a/module-domain/src/main/java/com/devtoon/jtoon/member/repository/MemberRepository.java b/module-domain/src/main/java/com/devtoon/jtoon/member/repository/MemberRepository.java index 11bca2bd..ec40db4d 100644 --- a/module-domain/src/main/java/com/devtoon/jtoon/member/repository/MemberRepository.java +++ b/module-domain/src/main/java/com/devtoon/jtoon/member/repository/MemberRepository.java @@ -1,7 +1,5 @@ package com.devtoon.jtoon.member.repository; -import java.util.Optional; - import org.springframework.data.jpa.repository.JpaRepository; import com.devtoon.jtoon.member.entity.Member; @@ -10,4 +8,5 @@ public interface MemberRepository extends JpaRepository { boolean existsByEmail(String email); Optional findByPhone(String phone); + Optional findByEmail(String email); } diff --git a/module-domain/src/main/java/com/devtoon/jtoon/member/request/SignUpDto.java b/module-domain/src/main/java/com/devtoon/jtoon/member/request/SignUpReq.java similarity index 97% rename from module-domain/src/main/java/com/devtoon/jtoon/member/request/SignUpDto.java rename to module-domain/src/main/java/com/devtoon/jtoon/member/request/SignUpReq.java index fed0846d..a0c50eba 100644 --- a/module-domain/src/main/java/com/devtoon/jtoon/member/request/SignUpDto.java +++ b/module-domain/src/main/java/com/devtoon/jtoon/member/request/SignUpReq.java @@ -12,7 +12,7 @@ import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; -public record SignUpDto( +public record SignUpReq( @Pattern(regexp = EMAIL_PATTERN) String email, @Pattern(regexp = PASSWORD_PATTERN) String password, @NotBlank @Size(max = 10) String name, diff --git a/module-internal/build.gradle b/module-internal/build.gradle index fd78076c..e0c58467 100644 --- a/module-internal/build.gradle +++ b/module-internal/build.gradle @@ -1,7 +1,4 @@ dependencies { // Web implementation 'org.springframework.boot:spring-boot-starter-web' - - // Security - implementation 'org.springframework.boot:spring-boot-starter-security' } diff --git a/module-internal/src/main/java/com/devtoon/jtoon/security/config/WebSecurityConfiguration.java b/module-internal/src/main/java/com/devtoon/jtoon/security/config/WebSecurityConfiguration.java deleted file mode 100644 index d0ff6dbd..00000000 --- a/module-internal/src/main/java/com/devtoon/jtoon/security/config/WebSecurityConfiguration.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.devtoon.jtoon.security.config; - -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.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -@EnableWebSecurity -public class WebSecurityConfiguration { - - @Bean - public PasswordEncoder encoder() { - return new BCryptPasswordEncoder(); - } - - @Bean - public SecurityFilterChain securityFilterChain (HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(request -> request - .anyRequest().permitAll()) - ; - return http.build(); - } -}