우선 간단한 게시판을 그리는 작업은 위 링크에서 확인하면 된다. 이글에서는 로그인 화면과 회원등록 화면을 추가하고, API 접근시 해당 회원정보가 접근해도 되는 사용자인지 간단한 검증과정까지의 과정을 담아보려고 한다.
사실상 위 글에서 화면이 추가되었으며, 이 때보다 좀 더 퀄리티 높고, 가독성 좋게 코드를 개선하며 로그인 기능을 구현하고자 한다.(저 글을 보면 쓸 당시의 나는 도대체 무슨 뻔뻔함이 가득했길래 글을 저렇게 써놓고 뿌듯해했을까 싶다...)
그렇다고 글을 지우기보단 저 글을 작성했던 당시의 나와 이 글을 쓰고 있는 나를 비교할 수 있는 좋은 증거이기도 하며, 후에는 지금 쓴 글 조차도 부끄러워 할 날이 분명 오겠지..
우선 서버쪽에 로그인에 필요한 API와 그 외 파일을 작성했다.
나는 로그인 과정을 이전과 마찬가지로 Spring security + jwt를 이용해 작업을 진행했다.
compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.7.0'
@RequiredArgsConstructor
@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter{
private final JwtAuthProvider jwtAuthProvider;
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOrigin("*");
configuration.addAllowedMethod("*");
configuration.addAllowedHeader("*");
configuration.setMaxAge((long) 3600);
configuration.setAllowCredentials(false);
configuration.addExposedHeader("accessToken");
configuration.addExposedHeader("content-disposition");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.antMatchers("/signup").permitAll() // 회원가입
.antMatchers("/signin/**").permitAll() // 로그인
.antMatchers("/exception/**").permitAll() // 예외처리 포인트
.anyRequest().hasRole("USER") // 이외 나머지는 USER 권한필요
// .anyRequest().permitAll()
.and()
.cors()
.and()
.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedPoint())
.and()
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtAuthProvider), UsernamePasswordAuthenticationFilter.class);
}
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private JwtAuthProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtAuthProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest)request;
HttpServletResponse httpRes = (HttpServletResponse) response;
try {
if("OPTIONS".equalsIgnoreCase(httpReq.getMethod())) {
httpRes.setStatus(HttpServletResponse.SC_OK);
} else {
String token = jwtTokenProvider.resolveToken(httpReq);
if (token != null) {
if(jwtTokenProvider.validateToken(token)) {
/** 사용자 인증토큰 검사 */
Authentication auth = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
filterChain.doFilter(request, response);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
@RequiredArgsConstructor
@Component
public class JwtAuthProvider {
@Value("${spring.jwt.secret.at}")
private String atSecretKey;
@PostConstruct
protected void init() {
atSecretKey = Base64.getEncoder().encodeToString(atSecretKey.getBytes());
}
private final UserDetailsService userDetailsService;
/**
* @throws Exception
* @method 설명 : jwt 토큰 발급
*/
public String createToken(
long userPk,
String email,
String nickname) {
/**
* 토큰발급을 위한 데이터는 UserDetails에서 담당
* 따라서 UserDetails를 세부 구현한 CustomUserDetails로 회원정보 전달
*/
CustomUserDetails user = new CustomUserDetails(
userPk, // 번호
email); // 이메일
// 유효기간설정을 위한 Date 객체 선언
Date date = new Date();
final JwtBuilder builder = Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject("accesstoken").setExpiration(new Date(date.getTime() + (1000L*60*60*12)))
.claim("userPk", userPk)
.claim("email", email)
.claim("roles", user.getAuthorities())
.signWith(SignatureAlgorithm.HS256, atSecretKey);
return builder.compact();
}
// 토큰에서 회원 정보 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(atSecretKey).parseClaimsJws(token).getBody().getSubject();
}
/**
* @method 설명 : 컨텍스트에 해당 유저에 대한 권한을 전달하고 API 접근 전 접근 권한을 확인하여 접근 허용 또는 거부를 진행한다.
*/
@SuppressWarnings("unchecked")
public Authentication getAuthentication(String token) {
// 토큰 기반으로 유저의 정보 파싱
Claims claims = Jwts.parser().setSigningKey(atSecretKey).parseClaimsJws(token).getBody();
long userPk = claims.get("userPk", Integer.class);
String email = claims.get("email", String.class);
CustomUserDetails userDetails = new CustomUserDetails(userPk, email);
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
/**
* @method 설명 : request객체 헤더에 담겨 있는 토큰 가져오기
*/
public String resolveToken(HttpServletRequest request) {
return request.getHeader("accesstoken");
}
/**
* @method 설명 : 토큰 유효시간 만료여부 검사 실행
*/
public boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(atSecretKey).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
간단하게 정리해보자면 spring security 설정을 통해 JwtAuthenticationFilter를 필터로 사용하고
JwtAuthenticationFilter 에서 인증토큰을 검증과정을 JwtAuthProvider를 통해 진행한다.
이 과정에 문제 발생 시 accessDeniedHandler와 authenticationEntryPoint 각각 지정포인트를 통해
각 상황에 맞게 예외처리를 한다.
그리고 로그인이니까 당연히 회원 엔티티가 필요하겠지??
@Getter
@Entity
@DynamicUpdate
@DynamicInsert
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class Members {
@Id
@GeneratedValue
private Long id;
@Column(length = 100, nullable = false)
private String email;
@Column(length = 200, nullable = false)
private String password;
@Column(length = 100, nullable = false)
private String name;
@Column(length = 100, nullable = false)
private String mobile;
@Column(length = 100, nullable = true)
private String nickname;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
@Builder
public Members(Long id, String email,
String password, String name,
String mobile, String nickname) {
this.id = id;
this.email = email;
this.password = password;
this.name = name;
this.mobile = mobile;
this.nickname = nickname;
}
public interface MemberRepository extends JpaRepository<Members, Long> {
Optional<Members> findByEmail(String email);
}
자 로그인이 이루어지기 위한 환경은 마련이 됐다. 이제 로그인 API를 만들어보자!!
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class AuthenticationDto {
private Long id;
private String email;
private String name;
private String nickname;
private String mobile;
private String regDate;
private String modDate;
}
public AuthenticationDto loginService(LoginDto loginDto) {
// dto -> entity
Members loginEntity = loginDto.toEntity();
// 회원 엔티티 객체 생성 및 조회시작
Members member = memberRepository.findByEmail(loginEntity.getEmail())
.orElseThrow(() -> new UserNotFoundException("User Not Found"));
if (!passwordEncoder.matches(loginEntity.getPassword(), member.getPassword()))
throw new ForbiddenException("Passwords do not match");
// 회원정보를 인증클래스 객체(authentication)로 매핑
AuthenticationDto authentication = modelMapper.toDto(member, AuthenticationDto.class);
return authentication;
}
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class LoginDto {
@NotBlank(message = "'email' is a required input value")
@Email(message = "It is not in email format")
private String email;
@NotBlank(message = "'password' is a required input value")
private String password;
public Members toEntity() {
Members build = Members.builder()
.email(email)
.password(password)
.build();
return build;
}
}
/**
* @method 설명 : 로그인
* @param loginDto
* @throws Exception
*/
@PostMapping(value = {"/signin"})
public ResponseEntity<AuthenticationDto> appLogin(
@Valid @RequestBody LoginDto loginDto) throws Exception {
AuthenticationDto authentication = apiSignService.loginService(loginDto);
return ResponseEntity.ok()
.header("accesstoken", jwtProvider
.createToken(
authentication.getId(),
authentication.getEmail())
.body(authentication);
}
자 로그인 API까지 완성한 상태다. 다음 글에서는 화면과 테스트까지 진행해볼것이다.