diff --git a/src/main/java/com/retrip/auth/AuthApplication.java b/src/main/java/com/retrip/auth/AuthApplication.java index 4d9faa0..f4e8520 100644 --- a/src/main/java/com/retrip/auth/AuthApplication.java +++ b/src/main/java/com/retrip/auth/AuthApplication.java @@ -1,9 +1,12 @@ package com.retrip.auth; +import com.retrip.auth.application.config.JwtConfig; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; @SpringBootApplication +@EnableConfigurationProperties({JwtConfig.class}) public class AuthApplication { public static void main(String[] args) { diff --git a/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java b/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java new file mode 100644 index 0000000..b026d5c --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java @@ -0,0 +1,37 @@ +package com.retrip.auth.application.config; + + +import com.retrip.auth.domain.entity.Member; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.stream.Collectors; + + +public class CustomUserDetails implements UserDetails { + + private final Member member; + + public CustomUserDetails(Member member) { + this.member = member; + } + + @Override + public Collection getAuthorities() { + return member.getAuthorities().getValues().stream() + .map(authority -> new SimpleGrantedAuthority(authority.getGrant().getCode())) + .collect(Collectors.toList()); + } + + @Override + public String getPassword() { + return member.getPassword().getValue(); + } + + @Override + public String getUsername() { + return member.getEmail().getValue(); + } +} diff --git a/src/main/java/com/retrip/auth/application/config/JwtConfig.java b/src/main/java/com/retrip/auth/application/config/JwtConfig.java new file mode 100644 index 0000000..30cc89f --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/JwtConfig.java @@ -0,0 +1,29 @@ +package com.retrip.auth.application.config; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties("token.jwt") +public class JwtConfig { + private final String secret; + private final String header; + private final String prefix; + private final AccessConfig access; + private final RefreshConfig refresh; + + @Getter + @RequiredArgsConstructor + public static class AccessConfig { + private final int expireMin; + } + + @Getter + @RequiredArgsConstructor + public static class RefreshConfig { + private final int expireMin; + } +} diff --git a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java new file mode 100644 index 0000000..357a3e3 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java @@ -0,0 +1,57 @@ +package com.retrip.auth.application.config; + +import com.retrip.auth.application.in.MemberService; +import com.retrip.auth.infra.adapter.in.rest.filter.JwtAuthenticationFilter; +import com.retrip.auth.infra.adapter.in.rest.filter.LoginAuthenticationFilter; +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.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +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.security.web.authentication.www.BasicAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); //암호 인코더 정의 + } + + @Bean + public LoginAuthenticationFilter loginAuthenticationFilter(JwtConfig jwtConfig, AuthenticationManager authenticationManager) { + return new LoginAuthenticationFilter(jwtConfig, authenticationManager); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter(JwtConfig jwtConfig){ + return new JwtAuthenticationFilter(jwtConfig); + } + @Bean + public AuthenticationManager authenticationManager( + HttpSecurity http, + UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider, + MemberService memberService) throws Exception { + return http.authenticationProvider(usernamePasswordAuthenticationProvider) + .userDetailsService(memberService) + .getSharedObject(AuthenticationManagerBuilder.class) + .build(); + } + + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, LoginAuthenticationFilter loginAuthenticationFilter, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .addFilterAt(loginAuthenticationFilter, BasicAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated()); + + return http.build(); + } +} diff --git a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthentication.java b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthentication.java new file mode 100644 index 0000000..f8162a0 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthentication.java @@ -0,0 +1,21 @@ +package com.retrip.auth.application.config; + +import java.util.Collection; + +import org.springframework.context.annotation.Bean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +public class UsernamePasswordAuthentication extends UsernamePasswordAuthenticationToken { + //인증 완료 + public UsernamePasswordAuthentication(Object principal, Object credentials, + Collection authorities) { + super(principal, credentials, authorities); + } + + //인증 X + public UsernamePasswordAuthentication(Object principal, Object credentials) { + super(principal, credentials); + } +} diff --git a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java new file mode 100644 index 0000000..8efb427 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java @@ -0,0 +1,44 @@ +package com.retrip.auth.application.config; + +import com.retrip.auth.application.in.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider { + + private final MemberService memberService; + private final PasswordEncoder passwordEncoder; + + @Override + public Authentication authenticate(Authentication authentication) + throws AuthenticationException { + String username = authentication.getName(); + String password = String.valueOf(authentication.getCredentials()); + + UserDetails user = memberService.loadUserByUsername(username); + + if (!passwordEncoder.matches(password, user.getPassword())) { + throw new BadCredentialsException("Bad credentials"); + } + + return new UsernamePasswordAuthenticationToken( + username, + password, + user.getAuthorities().stream().toList() + ); + } + + @Override + public boolean supports(Class authentication) { + return UsernamePasswordAuthentication.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/com/retrip/auth/application/in/MemberService.java b/src/main/java/com/retrip/auth/application/in/MemberService.java new file mode 100644 index 0000000..e5a7d97 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/MemberService.java @@ -0,0 +1,28 @@ +package com.retrip.auth.application.in; + +import com.retrip.auth.application.config.CustomUserDetails; +import com.retrip.auth.application.in.usercase.ManageMemberUseCase; +import com.retrip.auth.application.out.repository.MemberQueryRepository; +import com.retrip.auth.application.out.repository.MemberRepository; +import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.domain.exception.MemberNotFoundException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +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; + +@Service +@RequiredArgsConstructor +@Transactional +public class MemberService implements ManageMemberUseCase, UserDetailsService { + + private final MemberQueryRepository memberQueryRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Member member = memberQueryRepository.findByEmailWithAuthorities(username).orElseThrow(MemberNotFoundException::new); + return new CustomUserDetails(member); + } +} diff --git a/src/main/java/com/retrip/auth/application/in/request/LoginRequest.java b/src/main/java/com/retrip/auth/application/in/request/LoginRequest.java new file mode 100644 index 0000000..0b8f436 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/request/LoginRequest.java @@ -0,0 +1,9 @@ +package com.retrip.auth.application.in.request; + +import org.springframework.security.crypto.password.PasswordEncoder; + +public record LoginRequest ( + String email, + String password +) { +} diff --git a/src/main/java/com/retrip/auth/application/in/response/LoginResponse.java b/src/main/java/com/retrip/auth/application/in/response/LoginResponse.java new file mode 100644 index 0000000..f3e514b --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/response/LoginResponse.java @@ -0,0 +1,9 @@ +package com.retrip.auth.application.in.response; + +public record LoginResponse( + TokenResponse token +) { + public record TokenResponse(String accessToken, String refreshToken) { + + } +} diff --git a/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java b/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java new file mode 100644 index 0000000..5c3c073 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java @@ -0,0 +1,6 @@ +package com.retrip.auth.application.in.usercase; + + + +public interface ManageMemberUseCase { +} diff --git a/src/main/java/com/retrip/auth/application/out/repository/MemberQueryRepository.java b/src/main/java/com/retrip/auth/application/out/repository/MemberQueryRepository.java new file mode 100644 index 0000000..77cb4c8 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/out/repository/MemberQueryRepository.java @@ -0,0 +1,10 @@ +package com.retrip.auth.application.out.repository; + +import com.retrip.auth.domain.entity.Member; + +import java.util.Optional; +import java.util.UUID; + +public interface MemberQueryRepository { + Optional findByEmailWithAuthorities(String email); +} diff --git a/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java b/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java new file mode 100644 index 0000000..c5ff13b --- /dev/null +++ b/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java @@ -0,0 +1,10 @@ +package com.retrip.auth.application.out.repository; + +import com.retrip.auth.domain.entity.Member; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { +} diff --git a/src/main/java/com/retrip/auth/domain/entity/Authorities.java b/src/main/java/com/retrip/auth/domain/entity/Authorities.java new file mode 100644 index 0000000..5f845f2 --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/entity/Authorities.java @@ -0,0 +1,31 @@ +package com.retrip.auth.domain.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embeddable; +import jakarta.persistence.OneToMany; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +import static lombok.AccessLevel.PROTECTED; + +@Getter +@Embeddable +@NoArgsConstructor(access = PROTECTED, force = true) +public class Authorities { + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + private final List values = new ArrayList<>(); + + public Authorities(List authorities, Member member) { + validate(authorities); + authorities.forEach(authority -> this.values.add(Authority.create(authority, member))); + } + + private void validate(List authorities) { + if (authorities == null || authorities.isEmpty()) { + throw new IllegalArgumentException("권한이 없습니다."); + } + } +} diff --git a/src/main/java/com/retrip/auth/domain/entity/Authority.java b/src/main/java/com/retrip/auth/domain/entity/Authority.java new file mode 100644 index 0000000..51fe8c0 --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/entity/Authority.java @@ -0,0 +1,43 @@ +package com.retrip.auth.domain.entity; + +import com.retrip.auth.domain.vo.AuthorityGrant; +import jakarta.persistence.*; + +import java.time.LocalDate; +import java.util.UUID; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.authentication.jaas.AuthorityGranter; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Authority extends BaseEntity { + @Id + @Column(columnDefinition = "varbinary(16)") + private UUID id; + + @Column(name = "grant", length = 50, nullable = false) + private AuthorityGrant grant; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn( + name = "member_id", + nullable = false, + columnDefinition = "varbinary(16)", + foreignKey = @ForeignKey(name = "fk_authority_to_member") + ) + private Member member; + + private Authority(String grant, Member member) { + this.id = UUID.randomUUID(); + this.grant = AuthorityGrant.codeOf(grant); + this.member = member; + } + + public static Authority create(String grant, Member member) { + return new Authority(grant, member); + } +} diff --git a/src/main/java/com/retrip/auth/domain/entity/BaseEntity.java b/src/main/java/com/retrip/auth/domain/entity/BaseEntity.java new file mode 100644 index 0000000..a4cb68d --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/entity/BaseEntity.java @@ -0,0 +1,22 @@ +package com.retrip.auth.domain.entity; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public abstract class BaseEntity { + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime editedAt; +} + diff --git a/src/main/java/com/retrip/auth/domain/entity/Member.java b/src/main/java/com/retrip/auth/domain/entity/Member.java index b4f53dc..010c883 100644 --- a/src/main/java/com/retrip/auth/domain/entity/Member.java +++ b/src/main/java/com/retrip/auth/domain/entity/Member.java @@ -1,20 +1,22 @@ package com.retrip.auth.domain.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Version; +import com.retrip.auth.domain.vo.MemberEmail; +import com.retrip.auth.domain.vo.MemberName; +import com.retrip.auth.domain.vo.MemberPassword; +import jakarta.persistence.*; +import java.util.List; import java.util.UUID; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; +import org.springframework.security.crypto.password.PasswordEncoder; @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@AllArgsConstructor @Getter -public class Member { +public class Member extends BaseEntity { @Id @Column(columnDefinition = "varbinary(16)") private UUID id; @@ -22,6 +24,27 @@ public class Member { @Version private long version; + @Embedded + private MemberName name; + @Embedded + private MemberEmail email; + @Embedded + private MemberPassword password; + + @Embedded + private Authorities authorities; + + + public static Member create(String name, String email, String password, List authorities) { + Member member = Member.builder() + .id(UUID.randomUUID()) + .name(new MemberName(name)) + .email(new MemberEmail(email)) + .password(new MemberPassword(password)) + .build(); + member.authorities = new Authorities(authorities, member); + return member; + } } diff --git a/src/main/java/com/retrip/auth/domain/exception/MemberNotFoundException.java b/src/main/java/com/retrip/auth/domain/exception/MemberNotFoundException.java new file mode 100644 index 0000000..4e5be2f --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/exception/MemberNotFoundException.java @@ -0,0 +1,13 @@ +package com.retrip.auth.domain.exception; + +import com.retrip.auth.domain.exception.common.EntityNotFoundException; +import com.retrip.auth.domain.exception.common.ErrorCode; + +public class MemberNotFoundException extends EntityNotFoundException { + private static final ErrorCode errorCode = ErrorCode.MEMBER_NOT_FOUND; + + public MemberNotFoundException() { + super(errorCode); + } +} + diff --git a/src/main/java/com/retrip/auth/domain/exception/PasswordNotMatchException.java b/src/main/java/com/retrip/auth/domain/exception/PasswordNotMatchException.java new file mode 100644 index 0000000..4a7753a --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/exception/PasswordNotMatchException.java @@ -0,0 +1,13 @@ +package com.retrip.auth.domain.exception; + +import com.retrip.auth.domain.exception.common.EntityNotFoundException; +import com.retrip.auth.domain.exception.common.ErrorCode; + +public class PasswordNotMatchException extends EntityNotFoundException { + private static final ErrorCode errorCode = ErrorCode.PASSWORD_NOT_MATCH; + + public PasswordNotMatchException() { + super(errorCode); + } +} + diff --git a/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java b/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java index 162b707..45a157f 100644 --- a/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java +++ b/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java @@ -12,7 +12,10 @@ public enum ErrorCode { HANDLE_ACCESS_DENIED(FORBIDDEN, "Common-003", "Access is denied"), ENTITY_NOT_FOUND(BAD_REQUEST, "Common-004", "Entity not found"), ILLEGAL_STATE(BAD_REQUEST, "Common-005", "Illegal state"), - INVALID_ACCESS(FORBIDDEN, "Common-006","접근 권한이 존재하지 않습니다.") + INVALID_ACCESS(FORBIDDEN, "Common-006","접근 권한이 존재하지 않습니다."), + + MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "Member-001", "멤버 엔티티를 찾을 수 없습니다."), + PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED, "Member-001", "비밀 번호가 다릅니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/retrip/auth/domain/vo/AuthorityGrant.java b/src/main/java/com/retrip/auth/domain/vo/AuthorityGrant.java new file mode 100644 index 0000000..2444608 --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/vo/AuthorityGrant.java @@ -0,0 +1,27 @@ +package com.retrip.auth.domain.vo; + +import com.retrip.auth.domain.exception.common.InvalidValueException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Arrays; + +import lombok.*; + +@Getter +@AllArgsConstructor +public enum AuthorityGrant { + ADMIN("admin", "관리자"), + USER("user", "사용자"), + ; + + private final String code; + private final String viewName; + public static AuthorityGrant codeOf(String code) { + return Arrays.stream(AuthorityGrant.values()) + .filter(authorityGrant -> authorityGrant.getCode().equals(code)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 코드입니다.")); + } + +} diff --git a/src/main/java/com/retrip/auth/domain/vo/MemberEmail.java b/src/main/java/com/retrip/auth/domain/vo/MemberEmail.java new file mode 100644 index 0000000..eea7a45 --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/vo/MemberEmail.java @@ -0,0 +1,32 @@ +package com.retrip.auth.domain.vo; + +import com.retrip.auth.domain.exception.common.InvalidValueException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true) +public class MemberEmail { + private static final int EMAIL_LENGTH_LIMIT = 50; + + @Column(name = "email", nullable = false, length = EMAIL_LENGTH_LIMIT) + private String value; + + public MemberEmail(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value.length() > EMAIL_LENGTH_LIMIT) { + throw new InvalidValueException("유저 ID는 " + EMAIL_LENGTH_LIMIT + "자를 넘을 수 없습니다."); + } + } + +} diff --git a/src/main/java/com/retrip/auth/domain/vo/MemberPassword.java b/src/main/java/com/retrip/auth/domain/vo/MemberPassword.java new file mode 100644 index 0000000..190ddb6 --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/vo/MemberPassword.java @@ -0,0 +1,34 @@ +package com.retrip.auth.domain.vo; + +import com.retrip.auth.domain.exception.PasswordNotMatchException; +import com.retrip.auth.domain.exception.common.InvalidValueException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true) +public class MemberPassword { + private static final int PASSWORD_LENGTH_LIMIT = 60; + + @Column(name = "password", nullable = false, length = PASSWORD_LENGTH_LIMIT) + private String value; + + public MemberPassword(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value.length() > PASSWORD_LENGTH_LIMIT) { + throw new InvalidValueException("유저 비밀번호는 " + PASSWORD_LENGTH_LIMIT + "자를 넘을 수 없습니다."); + } + } +} diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..7f1927a --- /dev/null +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java @@ -0,0 +1,91 @@ +package com.retrip.auth.infra.adapter.in.rest.filter; + +import com.retrip.auth.application.config.JwtConfig; +import com.retrip.auth.application.config.UsernamePasswordAuthentication; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.crypto.SecretKey; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtConfig jwtConfig; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String token = getToken(request.getHeader("Authorization")); + SecretKey key = Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8)); + if (token == null || !validToken(token, key)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + Claims payload = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token).getPayload(); + String username = String.valueOf(payload.get("username")); + List authorities = getAuthorities(String.valueOf(payload.get("authorities"))); + + UsernamePasswordAuthentication auth = new UsernamePasswordAuthentication(username, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + filterChain.doFilter(request, response); + } + + + private String getToken(String authorization) { + if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) { + return authorization.substring(7); + } + return null; + } + + private boolean validToken(String token, SecretKey key) { + try { + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token); + return true; // 검증 성공 + } catch (SecurityException | MalformedJwtException e) { + log.info("Invalid JWT Token", e); + } catch (ExpiredJwtException e) { + log.info("Expired JWT Token", e); + } catch (UnsupportedJwtException e) { + log.info("Unsupported JWT Token", e); + } catch (IllegalArgumentException e) { + log.info("JWT claims string is empty.", e); + } + return false; + } + + private List getAuthorities(String authorities) { + return Arrays.stream(authorities.split(",")) + .map(String::trim) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + //로그인 제외 모든 필터 타기 + return request.getRequestURI().equals("/login"); + } +} diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java new file mode 100644 index 0000000..b390c11 --- /dev/null +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java @@ -0,0 +1,111 @@ +package com.retrip.auth.infra.adapter.in.rest.filter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.retrip.auth.application.config.JwtConfig; +import com.retrip.auth.application.config.UsernamePasswordAuthentication; +import com.retrip.auth.application.in.response.LoginResponse; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.crypto.SecretKey; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +public class LoginAuthenticationFilter extends OncePerRequestFilter { + private final JwtConfig jwtConfig; + private final AuthenticationManager manager; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String id = request.getHeader("id"); + String password = request.getHeader("password"); + if (!StringUtils.hasText(id) || !StringUtils.hasText(password)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + Authentication authentication = new UsernamePasswordAuthentication(id, password); + Authentication auth = manager.authenticate(authentication); + LoginResponse.TokenResponse tokenResponse = generateToken(auth); + + // JSON 직렬화 + String json = getResponseBody(tokenResponse); + + // 응답 Header 설정 + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + // 응답에 JSON 데이터 작성 + response.setStatus(HttpServletResponse.SC_OK); + response.getWriter().write(json); + response.getWriter().flush(); + } + + + public LoginResponse.TokenResponse generateToken(Authentication authentication) { + String authorities = String.join(",", authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList()); + LocalDateTime issuedTime = LocalDateTime.now(); + LocalDateTime accessTokenExpireTime = LocalDateTime.now().plusMinutes(jwtConfig.getAccess().getExpireMin()); + LocalDateTime refreshTokenExpireTime = LocalDateTime.now().plusMinutes(jwtConfig.getRefresh().getExpireMin()); + + Date issuedDate = Date.from(issuedTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()); + Date accessTokenExpireDate = Date.from(accessTokenExpireTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()); + Date refreshTokenExpireDate = Date.from(refreshTokenExpireTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()); + SecretKey key = Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8)); + + String accessToken = Jwts.builder() + .subject(authentication.getName()) + .claims( + Map.of( + "username", + authentication.getName(), + "authorities", + authorities + ) + ) + .expiration(accessTokenExpireDate) + .issuedAt(issuedDate) + .signWith(key) + .compact(); + + String refreshToken = Jwts.builder() + .subject(authentication.getName()) + .claims(Map.of("username", authentication.getName(), "authorities", authorities)) + .expiration(refreshTokenExpireDate) + .issuedAt(issuedDate) + .signWith(key) + .compact(); + return new LoginResponse.TokenResponse(accessToken, refreshToken); + } + + private static String getResponseBody(LoginResponse.TokenResponse tokenResponse) throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.writeValueAsString(tokenResponse); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + //로그인만 + return !request.getRequestURI().equals("/login"); + } +} diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/in/TestController.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/in/TestController.java new file mode 100644 index 0000000..af14c19 --- /dev/null +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/in/TestController.java @@ -0,0 +1,15 @@ +package com.retrip.auth.infra.adapter.in.rest.in; + +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/test") +public class TestController { + @GetMapping + public String test(Authentication authentication){ + return authentication.getName(); + } +} diff --git a/src/main/java/com/retrip/auth/infra/adapter/out/persistence/mysql/query/MemberQuerydslRepository.java b/src/main/java/com/retrip/auth/infra/adapter/out/persistence/mysql/query/MemberQuerydslRepository.java new file mode 100644 index 0000000..e1747e5 --- /dev/null +++ b/src/main/java/com/retrip/auth/infra/adapter/out/persistence/mysql/query/MemberQuerydslRepository.java @@ -0,0 +1,30 @@ +package com.retrip.auth.infra.adapter.out.persistence.mysql.query; + +import static com.retrip.auth.domain.entity.QAuthority.authority; +import static com.retrip.auth.domain.entity.QMember.member; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.retrip.auth.application.out.repository.MemberQueryRepository; +import com.retrip.auth.domain.entity.Member; + +import com.retrip.auth.domain.entity.QAuthority; + +import java.util.Optional; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class MemberQuerydslRepository implements MemberQueryRepository { + private final JPAQueryFactory query; + + @Override + public Optional findByEmailWithAuthorities(String email) { + return Optional.ofNullable(query.selectFrom(member) + .leftJoin(member.authorities.values, authority) + .fetchJoin() + .where(member.email.value.eq(email)) + .fetchOne()); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f3741b1..79a9a59 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -29,3 +29,12 @@ logging: web: client: RestTemplate: DEBUG +token: + jwt: + secret: Kf9uX!7zQa2$rB3cP#dLmV8@eYtNwZxGjHsTrC1o + header: Authorization + prefix: Bearer + access: + expire-min: 120 + refresh: + expire-min: 1051200 #2년 diff --git a/src/test/java/com/retrip/auth/common/config/QuerydslConfig.java b/src/test/java/com/retrip/auth/common/config/QuerydslConfig.java new file mode 100644 index 0000000..79e5387 --- /dev/null +++ b/src/test/java/com/retrip/auth/common/config/QuerydslConfig.java @@ -0,0 +1,20 @@ +package com.retrip.auth.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@TestConfiguration +public class QuerydslConfig { + + @Autowired EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilterTest.java b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..c14c28a --- /dev/null +++ b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilterTest.java @@ -0,0 +1,63 @@ +package com.retrip.auth.infra.adapter.in.rest.filter; + + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.retrip.auth.application.in.request.LoginRequest; +import com.retrip.auth.infra.adapter.in.rest.filter.base.BaseLoginAuthenticationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = true) +class JwtAuthenticationFilterTest extends BaseLoginAuthenticationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void JwtLogin() throws Exception { + memberRepository.save(member); + + // given + LoginRequest request = new LoginRequest("test@naver.com", "1234"); + + //when + // headers 생성 + HttpHeaders loginHeader = new HttpHeaders(); + loginHeader.add("id", request.email()); + loginHeader.add("password", request.password()); + MvcResult mvcResult = mockMvc.perform(get("/login") + .contentType(MediaType.APPLICATION_JSON) + .headers(loginHeader)) + .andExpect(status().isOk()) + .andReturn(); //로그인 결과 + // 응답 본문 추출 + String responseBody = mvcResult.getResponse().getContentAsString(); + + // JSON 파싱 + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode jsonNode = objectMapper.readTree(responseBody); + String accessToken = jsonNode.get("accessToken").asText(); + String refreshToken = jsonNode.get("refreshToken").asText(); + + + // when & then + HttpHeaders jwtHeader = new HttpHeaders(); + jwtHeader.add("Authorization", "Bearer " + accessToken); + mockMvc.perform(get("/test") + .contentType(MediaType.APPLICATION_JSON) + .headers(jwtHeader)) + .andExpect(status().isOk()) + .andExpect(content().string("test@naver.com")); + } +} diff --git a/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilterTest.java b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilterTest.java new file mode 100644 index 0000000..b687694 --- /dev/null +++ b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilterTest.java @@ -0,0 +1,47 @@ +package com.retrip.auth.infra.adapter.in.rest.filter; + + +import com.retrip.auth.application.in.request.LoginRequest; +import com.retrip.auth.infra.adapter.in.rest.filter.base.BaseLoginAuthenticationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = true) +class LoginAuthenticationFilterTest extends BaseLoginAuthenticationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void 로그인_성공() throws Exception { + memberRepository.save(member); + + // given + LoginRequest request = new LoginRequest("test@naver.com", "1234"); + + //when + // headers 생성 + HttpHeaders headers = new HttpHeaders(); + headers.add("id", request.email()); + headers.add("password", request.password()); + + // when & then + mockMvc.perform(get("/login") + .contentType(MediaType.APPLICATION_JSON) + .headers(headers)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.refreshToken").isNotEmpty()); + } +} diff --git a/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/base/BaseLoginAuthenticationTest.java b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/base/BaseLoginAuthenticationTest.java new file mode 100644 index 0000000..ff58e90 --- /dev/null +++ b/src/test/java/com/retrip/auth/infra/adapter/in/rest/filter/base/BaseLoginAuthenticationTest.java @@ -0,0 +1,41 @@ +package com.retrip.auth.infra.adapter.in.rest.filter.base; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.retrip.auth.application.in.MemberService; +import com.retrip.auth.application.out.repository.MemberQueryRepository; +import com.retrip.auth.application.out.repository.MemberRepository; +import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.infra.adapter.out.persistence.mysql.query.MemberQuerydslRepository; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.List; + +@SpringBootTest +public abstract class BaseLoginAuthenticationTest { + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected PasswordEncoder passwordEncoder; + @Autowired + protected JPAQueryFactory jpaQueryFactory; + protected MemberQueryRepository memberQueryRepository; + protected MemberService memberService; + + protected Member member; + + @BeforeEach + void setUp() { + memberQueryRepository = new MemberQuerydslRepository(jpaQueryFactory); + memberService = new MemberService(memberQueryRepository); + member = Member.create( + "테스트", + "test@naver.com", + passwordEncoder.encode("1234"), + List.of("admin") + ); + } +}