인증이 필요하지 않은 api 같은 경우 러프하게는 구현이 되었다. 이제 인증에 필요한 jwt토큰발급을 위한 세팅을 하였다.
인프런 최주호님의 무료강의를 참조하였다.
먼저 Cors 정책을 글로벌로 허용해주는 Config파일을 수정했다
@Configuration
public class CorsWebConfig implements WebMvcConfigurer {
@Bean
public CorsFilter corsFilter(){
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/**",config);
return new CorsFilter(source);
}
}
기존엔 메소드를 오버라이딩하여 진행하였는데 Bean으로 등록해서 Security에 필터로 추가한다.
그다음 시큐리티와 jwt를 쓰기위해 build.gradle에 라이브러리를 추가한다.
//security
implementation 'org.springframework.boot:spring-boot-starter-security'
//jwt
implementation 'com.auth0:java-jwt:3.18.3'
프로젝트에 정상적으로 추가가되었다.
그다음 세팅하기전에 사용자도메인을 생성하였다.
@Entity
@Getter
@NoArgsConstructor
public class User extends BaseEntity{
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
private String fullName;
private String password;
private String email;
private String address;
private String profileUrl;
private String accessToken;
private String roles;
public List<String> getRoleList(){
if(this.roles.length() > 0){
return Arrays.asList(this.roles.split(","));
}
return new ArrayList<>();
}
@Builder
public User(Long userId, String fullName, String password, String email, String address, String profileUrl, String accessToken, String roles) {
this.id = userId;
this.fullName = fullName;
this.password = password;
this.email = email;
this.address = address;
this.profileUrl = profileUrl;
this.accessToken = accessToken;
this.roles = roles;
}
User 도메인에서 명시하는 id는 사용자가 로그인시 입력하는 id가 아니라 그냥 pk로 찾기편하게 하기위해 추가한 키이다.
email 필드가 실제 사용자가 입력하는 id가되겠다.
그리고 이번프로젝트에 관리자롤에 관한 로직을 구현할진 모르겠지만 일단 권한필드도 주었는데
ROLE_USER , ROLE_ADMIN 이런식으로 들어갈거라서 메소드를만들어 ','를 기준으로 리스트로 쪼개지도록하였다.
그리고 마지막으로 생성자는 회원가입 테스트를 위해 만들어놓았다 그냥 @Builder 어노테이션 추가해서 굳이 쓸모없는 데이터는 안넣고
유저가 create 되도록 할거다.
그다음 인증된 유저정보를 담을 PrincipalDetails와 email을 파라미터로 실제 유효한 유저인지 쿼리를 조회하는 PrincipaDetailsService를 만들었다.
@Data
public class PrincipalDetails implements UserDetails {
private User user;
public PrincipalDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
user.getRoleList().forEach(r->{
authorities.add(()-> r);
});
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.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;
}
}
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User userEntity = userRepository.findByEmail(email);
return new PrincipalDetails(userEntity);
}
}
loadUserByUsername을 통해 이메일로 유저엔티티에 유효한데이터가있는지 검증을하고 그후 PrincipalDetails에 유저정보를 던져 오브젝트를 리턴한다.
이 메소드는 개발자가 직접 호출하진 않을거고 필터에 특정 시점에 메소드가 실행되면 자동으로 이 loadUserByUsername을 호출한다고한다.
이제 로그인시도시 , 인증이필요한 api로 request가 들어올시에 동작되는 필터를 구현한다.
/원래는 /login 요청해서 username,password를 post로전송하면 호출됨 근데 form로그인 disable해서안됨
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
// /login 요청을하면 로그인 시도를 위해서 실행됨
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// username,password 받아서 정상 계정인지 조회
//authenticationManager로 시도하면 PrincipalDetailService의 loadUserByUsername 함수 실행됨
//principaldetails를 세션에담아서 jwt리턴
try {
ObjectMapper om = new ObjectMapper();
User user = om.readValue(request.getInputStream(), User.class);
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(user.getEmail(),user.getPassword());
//principaldetailsservice loadUserByUsername함수가실행됨
//password는 스프링에서 알아서 처리해줌
Authentication authentication = authenticationManager.authenticate(token);
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
//세션에 authentication저장
return authentication;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
//attemptAuthentication이후 정상인증시 실행됨
//jwt토큰 생성후 클라이언트에게 jwt토큰을 응답해줌
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
String token = JWT.create()
.withSubject(JwtTokenInfo.getInfoByKey("secret"))
.withExpiresAt(new Date(System.currentTimeMillis() + Integer.parseInt(JwtTokenInfo.getInfoByKey("expiration"))))
.withClaim("id",principalDetails.getUser().getId())
.withClaim("email",principalDetails.getUser().getEmail())
.sign(Algorithm.HMAC512(JwtTokenInfo.getInfoByKey("secret")));
response.addHeader(JwtTokenInfo.getInfoByKey("header"),JwtTokenInfo.getInfoByKey("prefix") + token);
}
}
/login url로 요청이들어오면 attemptAuthentication메소드가 실행된다고한다.
아직 클라이언트 친구랑 얘기는 안해봤는데 아마 사용자의 입력정보는 아마도 body에 json형태로 쏘도록할거같다.
유저정보를 가져와서 방금얘기한 principaldetailsService에 loadByUserName을 호출하게해준다. 여기서 쿼리는 email만가지고 조회를하고 스프링에서 알아서 password 인증을 처리해준다고 한다.
그후 인증이 완료되면 실행되는 success메소드에서 토큰을 발급하여 응답헤더에 추가하여 클라이언트에 보내준다. 참고로 저 토큰에 들어가는 값들은 별도의 프로퍼티에 작성되어있고 서버실행시 값을 읽어들여 스태틱 map에 담아두고 전역으로쓴거다.
@Component
public class JwtTokenInfo{
private static Map<String,String> tokenMap = new HashMap<>();
private String secret;
private String expiration;
private String prefix;
private String header;
public JwtTokenInfo(@Value("${Jwt.secret}") String secret,
@Value("${Jwt.expiration}") String expiration,
@Value("${Jwt.prefix}") String prefix,
@Value("${Jwt.header}") String header) {
this.secret = secret;
this.expiration = expiration;
this.prefix = prefix;
this.header = header;
}
@PostConstruct
public void setTokenMap(){
tokenMap.put("secret",secret);
tokenMap.put("expiration",expiration);
tokenMap.put("prefix",prefix);
tokenMap.put("header",header);
}
public static String getInfoByKey(String key){
return tokenMap.get(key);
}
}
당연히 외부에서 수정하면안되니까 private에 get으로만 가져가도록.
이제 마지막으로 스프링시큐리티를 등록한다.
원래는 처음에하는데 저 필터들을 구현한 클래스들을 필터에등록하기때문에 그냥 블로그 작성할땐 마지막으로 뺐다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CorsFilter corsFilter;
private final UserRepository userRepository;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf()
.disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilter(corsFilter)
.addFilter(new JwtAuthenticationFilter(authenticationManager()))
.addFilter(new JwtAuthorizationFilter(authenticationManager(),userRepository))
.formLogin().disable()
.httpBasic().disable()
.authorizeRequests()
.antMatchers("/user/**").access("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
.anyRequest().permitAll();
}
}
일단 api 통신이여서 세션으로 관리는 안할거다. 그래서 세션은 그냥 비활성화해버리고
세션id인증방식역시 안쓰기때문에 http basic도 꺼버린다.
또한 폼로그인역시 api서버에서 필요없다 꺼준다.
그후 아까 커스텀해둔 필터들을 등록해주고 일단은 뭐 user가붙은 경로로 들어오는 api는 무조건 인증이 필요하게는 해두었다.
postman으로 테스트해보면 발급도 잘되고 이 발급된 토큰을 헤더에 담아서 보내면 /user/~~~형태의 url도 인증이 잘된다.
하지만 보통 토큰은 만료시간을 짧게주고 갱신해주는 시스템으로 구성이된다. 만료기간이 길면 한번 탈취당했을때 너무 크리티컬하니까.
다음엔 해당 토큰이 올바른토큰인지 만료된 토큰인지 구별해서 만료되었으면 refresh 토큰을 발급해주는 형태로 구현을 해봐야겠다
'Project > MangoPlate Clone' 카테고리의 다른 글
Jwt 리프레시 토큰을 이용한 액세스 토큰 재발급 (0) | 2023.03.10 |
---|---|
Jwt 토큰 예외처리 (0) | 2023.03.09 |
SpringSecurity 및 jwt 설정 1 (0) | 2023.02.27 |
리뷰 및 메뉴 페이징 목록 구현 (1) | 2023.02.27 |
EhCache 설정 (0) | 2023.02.24 |