회원가입에 대한 간단한 service를 구현한 후, security를 적용시켜 보자.
중복 아이디 방지 등 간단한 회원가입기능을 구현해 준다.
security관련 파일을 넣어주면,build.gradle에 securitiy관련을 설정하지 않아서 오류가 생기게 된다.
// security/config/WebSecurityConfig
package com.greenart.flo_service.security.config;
import com.greenart.flo_service.security.filter.JwtAuthenticationFilter;
import com.greenart.flo_service.security.provider.JwtTokenProvider;
import com.greenart.flo_service.security.vo.PermitSettings;
import lombok.RequiredArgsConstructor;
import org.hibernate.validator.internal.util.stereotypes.Immutable;
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.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.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
// permitSettings.getPermitAllUrls()에 들어가는 값을 application.yaml로 옮겨 놓음. - 여기서 수정 시 오류 발생 가능성 큼
private final JwtTokenProvider jwtTokenProvider;
private final PermitSettings permitSettings;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// .cors().and() : vue를 위해 추가한 부분
http.cors().and()
.httpBasic().disable().csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and().authorizeHttpRequests()
// permitSettings의 getPermitAllUrls안에 있는 경로는 모두 권한이 없어도 접근 허가
.requestMatchers(permitSettings.getPermitAllUrls()).permitAll()
// .requestMatchers("/api/member/join", "/api/member/login").permitAll()
// 그외의 경로는 권한이 있어야 함
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// vue를 위해 추가한 부분
@Bean
public CorsConfigurationSource corsConfigurationSource() {
final CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.setAllowCredentials(true);
corsConfiguration.setAllowedHeaders(List.of("Authorization","Cache-Control","Content-Type"));
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**",corsConfiguration);
return source;
}
}
//security/filter/JwtAuthenticationFilter
package com.greenart.flo_service.security.filter;
import com.greenart.flo_service.security.provider.JwtTokenProvider;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilter {
private final JwtTokenProvider jwtTokenProvider;
// 오류 상황이 발생했을 때, 어떤 값을 돌려줄 것이냐
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = resolveToken((HttpServletRequest) request);
if(token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
}
return null;
}
}
//security/provider/JwtTokenProvider
package com.greenart.flo_service.security.provider;
import com.greenart.flo_service.security.vo.TokenVO;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
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 java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
// JWT 생성해주는 것 - 권한정보 생성, 유효성 검사
@Component
public class JwtTokenProvider {
private final Key key;
// 토큰의 유효시간 설정 - 10분
private final Integer tokenExpireMinutes = 60 * 24;
// 토큰의 유효시간 설정 - 60분
private final Integer refreshExpireMinutes = 60 * 24 * 7;
public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public TokenVO generateToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
Date expires = new Date((new Date()).getTime()+tokenExpireMinutes*60*1000);
Date refreshExpires = new Date((new Date()).getTime()+refreshExpireMinutes*60*1000);
String accessToken = Jwts.builder().setSubject(authentication.getName()).claim("auth", authorities).setExpiration(expires)
.signWith(key, SignatureAlgorithm.HS256).compact();
String refreshToken = Jwts.builder().setExpiration(refreshExpires)
.signWith(key, SignatureAlgorithm.HS256).compact();
return TokenVO.builder().grantType("Bearer").accessToken(accessToken).refreshToken(refreshToken).build();
}
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
// 토큰 유효성 검사
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
// 토큰이 등록 되지 않음.
System.out.println("Invalid JWT Token"+e);
} catch (ExpiredJwtException e) {
// 토큰이 유효기간이 지남.
System.out.println("Expired JWT Token"+e);
} catch(UnsupportedJwtException e) {
System.out.println("Unsupported JWT Token"+e);
} catch(IllegalArgumentException e) {
System.out.println("JWT claims string is empty."+e);
}
return false;
}
}
//security/service/CustomUserDetailService
package com.greenart.flo_service.security.service;
import com.greenart.flo_service.entity.AdminEntity;
import com.greenart.flo_service.repository.AdminRepository;
import lombok.RequiredArgsConstructor;
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;
// 서버 내부에 만들어져 있는 유저의 정보를 저장하게 되어 있음 - user detail을 생성해서
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
private final AdminRepository adminRepo;
private final PasswordEncoder passwordEncoder;
// 서비스가 발생하면서 user detail을 생성하고 저장하게 오버라이드 되어있음
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return createUserDetails(adminRepo.findByAdminId(username));
}
public UserDetails createUserDetails(AdminEntity member) {
return User.builder().username(member.getAdminId())
.password(passwordEncoder.encode(member.getAdminPwd()))
.roles(member.getAdminRole())
.build();
}
}
//secutiry/vo/PermitSettings
package com.greenart.flo_service.security.vo;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "permission")
@Data
public class PermitSettings {
String[] permitAllUrls;
}
//security/vo/TokenVO
package com.greenart.flo_service.security.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class TokenVO {
private String grantType;
private String accessToken;
private String refreshToken;
}
build.gradle에서 아래 내용을 입력해 준다.
// Spring Security Test Implementation testImplementation'org.springframework.security:spring-security-test' // Spring Security & JWT Implementation implementation 'org.springframework.boot:spring-boot-starter-security' implementation "io.jsonwebtoken:jjwt-api:0.11.5" implementation "io.jsonwebtoken:jjwt-jackson:0.11.5" implementation "io.jsonwebtoken:jjwt-impl:0.11.5"
gradle에 변경사항이 생기면 오른쪽 코끼리모양이 뜬다. 이것을 클릭한다.
gradle에 적용을 한 후에는, application.yaml에 아래 내용을 추가하면 프로그램이 돌아간다.// application.yaml jwt: secretKey: sfjkaspfoiwefjllkSDFIJWELjnwpoqfkjaklnfvpCVMLERPfwscxklwfvwAJFSLKJEmndlwefj permission: permit-all-urls: - /api/member/login - /api/member/join
secretKey는 일정길이 이상으로 설정해 주어야 한다.
MemberInfo를 Security 사용가능하게 하려면 MemberInterface를 구현해야한다.
MemberInfoVO가 UserDetails를 상속받도록 설정하고 빨간줄에 커서를 대고, Alt+Shift+Enter를 클릭하면 사진과 같은 창이 뜬다.
모든 항목을 만들어주면, 간단하게 아래 코드와 같이 여러 기능이 생성된다.
// MemberInfoVO
package com.example.security_test.vo.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class MemberInfoVO implements UserDetails {
private String mi_seq;
private String mi_id;
private String mi_pwd;
private String mi_name;
private String mi_nickname;
private Integer mi_status;
private LocalDateTime mi_reg_dt;
private String mi_role;
@Override
@JsonIgnore
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<GrantedAuthority> roles = new HashSet<>();
roles.add(new SimpleGrantedAuthority(this.mi_role));
return roles;
}
@Override
@JsonIgnore
public String getPassword() {
return null;
}
@Override
@JsonIgnore
public String getUsername() {
return this.mi_id;
}
@Override
@JsonIgnore
public boolean isAccountNonExpired() {
return true;
}
@Override
@JsonIgnore
public boolean isAccountNonLocked() {
return true;
}
@Override
@JsonIgnore
public boolean isCredentialsNonExpired() {
return true;
}
@Override
@JsonIgnore
public boolean isEnabled() {
return mi_status == 1;
}
}
//LoginResponseVo.java
package com.example.security_test.vo.response;
import com.example.security_test.security.vo.TokenVO;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.el.parser.Token;
import org.springframework.http.HttpStatus;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class LoginResponseVO {
private Boolean status;
private String message;
private TokenVO token;
private HttpStatus code;
}
토큰을 발급하기 위해서 Service에 아래 항목들을 연결해 준다.
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailService customUserDetailService;
그리고 Service에서 로그인 하는 기능을 구현한다.
package com.greenart.flo_service.security.config;
import com.greenart.flo_service.security.filter.JwtAuthenticationFilter;
import com.greenart.flo_service.security.provider.JwtTokenProvider;
import com.greenart.flo_service.security.vo.PermitSettings;
import lombok.RequiredArgsConstructor;
import org.hibernate.validator.internal.util.stereotypes.Immutable;
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.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.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
// permitSettings.getPermitAllUrls()에 들어가는 값을 application.yaml로 옮겨 놓음. - 여기서 수정 시 오류 발생 가능성 큼
private final JwtTokenProvider jwtTokenProvider;
private final PermitSettings permitSettings;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// .cors().and() : vue를 위해 추가한 부분
http.cors().and()
.httpBasic().disable().csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and().authorizeHttpRequests()
// permitSettings의 getPermitAllUrls안에 있는 경로는 모두 권한이 없어도 접근 허가
.requestMatchers(permitSettings.getPermitAllUrls()).permitAll()
// .requestMatchers("/api/member/join", "/api/member/login").permitAll()
// 그외의 경로는 권한이 있어야 함
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// vue를 위해 추가한 부분
@Bean
public CorsConfigurationSource corsConfigurationSource() {
final CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.setAllowCredentials(true);
corsConfiguration.setAllowedHeaders(List.of("Authorization","Cache-Control","Content-Type"));
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**",corsConfiguration);
return source;
}
}
로그인 시 아래와 같이 토큰이 발급되는 것을 확인 할 수 있다.
이제부터, application.yaml에 등록되지 않은 경로의 요청은 토큰 값을 함께 실어보내지 않는다면 아래와 같이 기능이 제대로 작동하지 않음을 알 수 있다.
Autorization에 BrearerHeader에 token 값을 실어 보내면 값이 출력된다.
아래 두 부분의 위치를 바꿔주면, 특정한 api에만 security가 걸린다.