diff --git a/.gitignore b/.gitignore index 681f8ac..9656dea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ HELP.md -.gradle +.gradle/** build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ diff --git a/.gradle/7.3.2/executionHistory/executionHistory.lock b/.gradle/7.3.2/executionHistory/executionHistory.lock index 338b8ae..865d1de 100644 Binary files a/.gradle/7.3.2/executionHistory/executionHistory.lock and b/.gradle/7.3.2/executionHistory/executionHistory.lock differ diff --git a/.gradle/7.3.2/fileHashes/fileHashes.lock b/.gradle/7.3.2/fileHashes/fileHashes.lock index 90762aa..5e918f0 100644 Binary files a/.gradle/7.3.2/fileHashes/fileHashes.lock and b/.gradle/7.3.2/fileHashes/fileHashes.lock differ diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock index c742a4f..b08fb54 100644 Binary files a/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/build.gradle b/build.gradle index 6d7ced6..65c28d0 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,6 @@ ext { 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' compileOnly 'org.projectlombok:lombok' @@ -33,6 +32,10 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + + + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt:0.9.1' testImplementation 'org.springframework.security:spring-security-test' } diff --git a/src/main/java/com/codingwasabi/trti/config/auth/jwt/JwtEntryPoint.java b/src/main/java/com/codingwasabi/trti/config/auth/jwt/JwtEntryPoint.java new file mode 100644 index 0000000..604b499 --- /dev/null +++ b/src/main/java/com/codingwasabi/trti/config/auth/jwt/JwtEntryPoint.java @@ -0,0 +1,26 @@ +package com.codingwasabi.trti.config.auth.jwt; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtEntryPoint implements AuthenticationEntryPoint { + private final JwtProvider jwtProvider; + + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + String exception = jwtProvider.setInvalidJwtMessage(jwtProvider.resolve(request)); + // JWT 관련 인증 예외를 처리한다. 403 + response.sendError(HttpServletResponse.SC_FORBIDDEN, exception); + } +} diff --git a/src/main/java/com/codingwasabi/trti/config/auth/jwt/JwtFilter.java b/src/main/java/com/codingwasabi/trti/config/auth/jwt/JwtFilter.java new file mode 100644 index 0000000..1f2bfc4 --- /dev/null +++ b/src/main/java/com/codingwasabi/trti/config/auth/jwt/JwtFilter.java @@ -0,0 +1,30 @@ +package com.codingwasabi.trti.config.auth.jwt; + +import lombok.RequiredArgsConstructor; +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.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + private final JwtProvider jwtProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String token = jwtProvider.resolve(request); + // 토큰이 유효한 경우에는 인증정보를 추출한다. + if(jwtProvider.validate(token)) { + Authentication authentication = jwtProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/codingwasabi/trti/config/auth/jwt/JwtProvider.java b/src/main/java/com/codingwasabi/trti/config/auth/jwt/JwtProvider.java new file mode 100644 index 0000000..897afa3 --- /dev/null +++ b/src/main/java/com/codingwasabi/trti/config/auth/jwt/JwtProvider.java @@ -0,0 +1,119 @@ +package com.codingwasabi.trti.config.auth.jwt; + +import com.codingwasabi.trti.config.auth.security.MemberAdaptor; +import io.jsonwebtoken.*; +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.UserDetailsService; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import javax.servlet.http.HttpServletRequest; +import java.util.Base64; +import java.util.Date; + +@Component +public class JwtProvider { + private final UserDetailsService securityService; + private final Long validTimeMilli; + private String key; + + public JwtProvider(@Value("${jwt.validTime}") Long validTime, + @Value("${jwt.secret}") String key, + UserDetailsService securityService) { + this.securityService = securityService; + this.validTimeMilli = validTime * 1000L; + this.key = key; + } + + @PostConstruct + protected void init() { + key = Base64.getEncoder().encodeToString(key.getBytes()); + } + + /** + * JWT 생성 + * @param email + * @return + */ + public String create(String email, String providerId) { + Date now = new Date(); + Claims claims = Jwts.claims().setSubject(email); + claims.put("providerId", providerId); + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + validTimeMilli)) + .signWith(SignatureAlgorithm.HS256, key) + .compact(); + } + + + /** + * JWT 유효성 검증 (key 검증 및 만료일자 검증) + * @param jwtToken + * @return + */ + public boolean validate(String jwtToken) { + try { + Jws claimsJws = Jwts.parser() + .setSigningKey(key) + .parseClaimsJws(jwtToken); + + return claimsJws.getBody().getExpiration().before(new Date()); + } catch (Exception e) { + return false; + } + } + + /** + * JWT 에서 회원정보 추출 + * @param jwtToken + * @return + */ + public Authentication getAuthentication(String jwtToken) { + MemberAdaptor memberAdaptor = (MemberAdaptor) securityService + .loadUserByUsername(getEmailFromToken(jwtToken)); + + return new UsernamePasswordAuthenticationToken(memberAdaptor, null, + memberAdaptor.getAuthorities()); + } + + /** + * JWT 에서 email 추출 + * @param jwtToken + * @return + */ + private String getEmailFromToken(String jwtToken) { + return Jwts.parser() + .setSigningKey(key) + .parseClaimsJws(jwtToken) + .getBody() + .getSubject(); + } + + /** + * request 의 header 로부터 토큰 추출 + * @param request + * @return + */ + public String resolve(HttpServletRequest request) { + return request.getHeader("X-AUTH-TOKEN"); + } + + public String setInvalidJwtMessage(String jwtToken) { + try { + Jwts.parser().setSigningKey(key).parseClaimsJws(jwtToken); + return "Server 내부에서 발생한 인증 오류입니다. Concorn 개발팀에 문의하세요."; + } catch (UnsupportedJwtException | MalformedJwtException e) { + return "지원하지 않는 구성의 JWT 입니다. 버전 혹은 암호화 방식을 확인하세요."; + } catch (ExpiredJwtException e) { + return "만료된 JWT 입니다."; + } catch (SignatureException e) { + return "서버에서 허용하지 않은 key 로 생성한 JWT 입니다. 접근 거부"; + } catch (IllegalArgumentException e) { + return "JWT 가 공백 형태입니다. 헤더를 확인해주세요."; + } + } +} diff --git a/src/main/java/com/codingwasabi/trti/config/auth/oauth/kind/KakaoOauthInfo.java b/src/main/java/com/codingwasabi/trti/config/auth/oauth/kind/KakaoOauthInfo.java new file mode 100644 index 0000000..4bef2a7 --- /dev/null +++ b/src/main/java/com/codingwasabi/trti/config/auth/oauth/kind/KakaoOauthInfo.java @@ -0,0 +1,68 @@ +package com.codingwasabi.trti.config.auth.oauth.kind; + +import com.codingwasabi.trti.domain.member.model.entity.Member; +import com.codingwasabi.trti.domain.member.model.enumValue.Gender; + +import java.util.Map; + +import static com.codingwasabi.trti.config.auth.oauth.provider.OauthProvider.KAKAO; + +public class KakaoOauthInfo implements OauthInfo{ + private final Map attributeMap; + + private KakaoOauthInfo(Map attributeMap) { + this.attributeMap = attributeMap; + } + + public static KakaoOauthInfo from(Map attributeMap) { + return new KakaoOauthInfo(attributeMap); + } + + @Override + public Gender getGender() { + return (Gender) attributeMap.get("gender"); + } + + @Override + public String getAgeRange() { + return (String) attributeMap.get("ageRange"); + } + + @Override + public String getImagePath() { + return (String) attributeMap.get("profileImage"); + } + + @Override + public String getProviderId() { + return "k_" + attributeMap.get("kakaoId"); + } + + @Override + public String getProviderKind() { + return KAKAO.toString(); + } + + @Override + public String getEmail() { + return (String) attributeMap.get("email"); + } + + @Override + public String getNickname() { + return (String) attributeMap.get("nickname"); + } + + @Override + public Member getEntity() { + return Member.builder() + .oauthId(getProviderId()) + .email(getEmail()) + .ageRange(getAgeRange()) + .gender(getGender()) + .imagePath(getImagePath()) + .nickname(getNickname()) + .provider(KAKAO) + .build(); + } +} diff --git a/src/main/java/com/codingwasabi/trti/config/auth/oauth/kind/OauthInfo.java b/src/main/java/com/codingwasabi/trti/config/auth/oauth/kind/OauthInfo.java new file mode 100644 index 0000000..02c22cc --- /dev/null +++ b/src/main/java/com/codingwasabi/trti/config/auth/oauth/kind/OauthInfo.java @@ -0,0 +1,22 @@ +package com.codingwasabi.trti.config.auth.oauth.kind; + +import com.codingwasabi.trti.domain.member.model.entity.Member; +import com.codingwasabi.trti.domain.member.model.enumValue.Gender; + +public interface OauthInfo { + String getProviderId(); + + String getProviderKind(); + + String getEmail(); + + String getNickname(); + + Gender getGender(); + + String getAgeRange(); + + String getImagePath(); + + Member getEntity(); +} diff --git a/src/main/java/com/codingwasabi/trti/config/auth/oauth/provider/OauthProvider.java b/src/main/java/com/codingwasabi/trti/config/auth/oauth/provider/OauthProvider.java new file mode 100644 index 0000000..f28c487 --- /dev/null +++ b/src/main/java/com/codingwasabi/trti/config/auth/oauth/provider/OauthProvider.java @@ -0,0 +1,5 @@ +package com.codingwasabi.trti.config.auth.oauth.provider; + +public enum OauthProvider { + KAKAO; +} diff --git a/src/main/java/com/codingwasabi/trti/config/auth/oauth/service/OauthService.java b/src/main/java/com/codingwasabi/trti/config/auth/oauth/service/OauthService.java new file mode 100644 index 0000000..b628977 --- /dev/null +++ b/src/main/java/com/codingwasabi/trti/config/auth/oauth/service/OauthService.java @@ -0,0 +1,29 @@ +package com.codingwasabi.trti.config.auth.oauth.service; + +import com.codingwasabi.trti.config.auth.oauth.kind.KakaoOauthInfo; +import com.codingwasabi.trti.config.auth.oauth.kind.OauthInfo; +import com.codingwasabi.trti.config.auth.oauth.provider.OauthProvider; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +public class OauthService { + public OauthInfo filtrateOauth(String provider, Map requestMap) { + if(isKakao(provider)) { + return getKakao(requestMap); + } + + // error code 추가 + // "올바르지 못한 oauth 접근" + throw new IllegalArgumentException("Error"); + } + + private OauthInfo getKakao(Map requestMap) { + return KakaoOauthInfo.from(requestMap); + } + + private boolean isKakao(String provider) { + return provider.equals(OauthProvider.KAKAO); + } +} diff --git a/src/main/java/com/codingwasabi/trti/config/auth/security/MemberAdaptor.java b/src/main/java/com/codingwasabi/trti/config/auth/security/MemberAdaptor.java new file mode 100644 index 0000000..50ac5aa --- /dev/null +++ b/src/main/java/com/codingwasabi/trti/config/auth/security/MemberAdaptor.java @@ -0,0 +1,57 @@ +package com.codingwasabi.trti.config.auth.security; + +import com.codingwasabi.trti.domain.member.model.entity.Member; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; + +public class MemberAdaptor implements UserDetails { + + @Getter + public final Member member; + + public MemberAdaptor(Member member) { + this.member = member; + } + + @Override + public Collection getAuthorities() { + Collection authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority(member.getAuthority().getRole())); + return authorities; + } + + @Override + public String getPassword() { + return null; + } + + @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/src/main/java/com/codingwasabi/trti/config/auth/security/MemberDetailsServiceImpl.java b/src/main/java/com/codingwasabi/trti/config/auth/security/MemberDetailsServiceImpl.java new file mode 100644 index 0000000..b2bfc84 --- /dev/null +++ b/src/main/java/com/codingwasabi/trti/config/auth/security/MemberDetailsServiceImpl.java @@ -0,0 +1,24 @@ +package com.codingwasabi.trti.config.auth.security; + +import com.codingwasabi.trti.domain.member.model.entity.Member; +import com.codingwasabi.trti.domain.member.repository.MemberRepository; +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 +public class MemberDetailsServiceImpl implements UserDetailsService { + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + // Error code 추가 예정 + Member member = memberRepository.findByEmail(username) + .orElseThrow(() -> new IllegalArgumentException("ERROR")); + + return new MemberAdaptor(member); + } +} diff --git a/src/main/java/com/codingwasabi/trti/config/auth/security/SecurityConfig.java b/src/main/java/com/codingwasabi/trti/config/auth/security/SecurityConfig.java new file mode 100644 index 0000000..d9aaedf --- /dev/null +++ b/src/main/java/com/codingwasabi/trti/config/auth/security/SecurityConfig.java @@ -0,0 +1,50 @@ +package com.codingwasabi.trti.config.auth.security; + +import com.codingwasabi.trti.config.auth.jwt.JwtFilter; +import com.codingwasabi.trti.config.auth.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +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.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig extends WebSecurityConfigurerAdapter { + private final String[] AUTHENTICATED_URI_LIST = { + "" + }; + private final AuthenticationEntryPoint jwtEntryPoint; + private final JwtProvider jwtProvider; + + @Override + public AuthenticationManager authenticationManagerBean() throws Exception { + return super.authenticationManagerBean(); + } + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .httpBasic().disable() + .csrf().disable() + .cors() + .and() + .exceptionHandling() + .authenticationEntryPoint(jwtEntryPoint) + .and() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .authorizeRequests() + + // 일단 다 허용 + .anyRequest().permitAll() + + .and() + .addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class); + ; + } +} diff --git a/src/main/java/com/codingwasabi/trti/domain/member/model/entity/Member.java b/src/main/java/com/codingwasabi/trti/domain/member/model/entity/Member.java index f0fd0a1..10029de 100644 --- a/src/main/java/com/codingwasabi/trti/domain/member/model/entity/Member.java +++ b/src/main/java/com/codingwasabi/trti/domain/member/model/entity/Member.java @@ -1,17 +1,18 @@ package com.codingwasabi.trti.domain.member.model.entity; +import com.codingwasabi.trti.config.auth.oauth.provider.OauthProvider; import com.codingwasabi.trti.domain.common.Period; +import com.codingwasabi.trti.domain.member.model.enumValue.Authority; import com.codingwasabi.trti.domain.member.model.enumValue.Gender; import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.Getter; import lombok.NoArgsConstructor; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; +import javax.persistence.*; @Entity +@Getter @Builder @NoArgsConstructor @AllArgsConstructor @@ -26,9 +27,22 @@ public class Member extends Period { private String email; - private Gender gender; - private String ageRange; private String imagePath; + + @Enumerated(EnumType.STRING) + private Gender gender; + + @Enumerated(EnumType.STRING) + private OauthProvider provider; + + @Column + @Builder.Default + @Enumerated(EnumType.STRING) + private Authority authority = Authority.USER; + + public Authority getAuthority() { + return authority; + } } diff --git a/src/main/java/com/codingwasabi/trti/domain/member/model/enumValue/Authority.java b/src/main/java/com/codingwasabi/trti/domain/member/model/enumValue/Authority.java new file mode 100644 index 0000000..b91db9e --- /dev/null +++ b/src/main/java/com/codingwasabi/trti/domain/member/model/enumValue/Authority.java @@ -0,0 +1,13 @@ +package com.codingwasabi.trti.domain.member.model.enumValue; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Authority { + USER("ROLE_USER"), + ADMIN("ROLE_ADMIN"); + + private String role; +} diff --git a/src/main/java/com/codingwasabi/trti/domain/member/repository/MemberRepository.java b/src/main/java/com/codingwasabi/trti/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..c29df6b --- /dev/null +++ b/src/main/java/com/codingwasabi/trti/domain/member/repository/MemberRepository.java @@ -0,0 +1,10 @@ +package com.codingwasabi.trti.domain.member.repository; + +import com.codingwasabi.trti.domain.member.model.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByEmail(String username); +}