개요
웹서비스가 가춰야하는 기능 중 가장 중요한 것이 사용자 인증 기능입니다. 저는 토큰 기반의 인증방식을 선택했습니다
.
인증 기능을 구현하는 가장 고전적인 방식은 세션/쿠키를 이용하는 것입니다. 하지만 세션은 서버의 일부 공간을 차지한다는 단점이 있습니다. 이에 비해 토큰 기반의 인증 방식은 클라이언트는 서버가 제공한 토큰을 가지고 있으면 비밀키(secret key)를 이용해 서명을 하여 인증 기능을 수행합니다. 즉, 서버는 비밀 키만을 가지고 있으면 되고, 추가적인 공간이 필요 없습니다.
JSON Web Token (JWT) is a proposed Internet standard for creating data with optional signature and/or optional encryption whose payload holds JSON that asserts some number of claims (wikipedia)
JWT는 비대칭 암호화 기반으로 데이터를 생성하는 인터넷 표준 기술입니다. 웹에서 토큰 기반의 인증을 구현할때 가장 흔히 쓰이는 방식입니다. JWT와 토큰 기반의 인증에 대한 더 자세한 글은 아래 포스팅을 참고해 주세요!!!
참고글 : https://ohreallystore.tistory.com/86
JWT(Json Web Token)의 구조
JWT는 String 타입의 사용자를 식별하는 토큰 xxxxx.yyyyyy.zzzz... 위와 같이 점(.)으로 구분된 3개의 문자열이 합쳐진 하나의 문자열 형태임. 3개의 문자열은 각각 Header Payload Signature Header 토큰의 타입과
ohreallystore.tistory.com
JWT를 기반으로 인증을 구현하는 것은 세션에 비해 꽤나 복잡합니다,,, 하나하나 찬찬히 설명해볼게용~
Spring Security
토큰 관련 기능을 개발하기 앞서, 먼저 기본적인 인증/인가를 위한 기능을 구현해야 합니다. 저는 스프링이 기본으로 제공하는 인증/보안 프레임워크인 Spring Security를 사용했습니다. Spring Security는 필터 체인(filter chain)을 이용하여 인증을 처리합니다. 클라이언트로 부터 요청이 들어오면 필터 체인이 작동하여 모든 필터를 통과한 요청만이 인증된 요청으로서 처리됩니다.
아래 그림은 기본 설정을 했을때 생성되는 필터의 목록입니다.
저는 JWT를 이용하여 인증을 처리할 것이므로 스프링 시큐리티가 기본으로 제공하는 http 기본 필터나 crsf 필터 등은 사용하지 않을 것입니다. 또한, 토큰 인증을 수행하는 개발자 지정 필터를 추가해 주어야 합니다. 그러기 위해 Config 파일을 생성하여 직접SecurityFilterChain 빈(bean)을 생성해 줄 것입니다.
SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.requestMatchers("/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider, corsConfigurationSource()), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.accessDeniedHandler(new AccessDeniedHandler() {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(403);
response.setCharacterEncoding("utf-8");
response.setContentType("text/html; charset=UTF-8");
response.getWriter().write("권한이 없는 사용자입니다");
}
})
.authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setStatus(401);
response.setCharacterEncoding("utf-8");
response.setContentType("text/html; charset=UTF-8");
response.getWriter().write("인증에 실패하였습니다");
}
})
.and()
.cors();
return http.build();
}
위 코드는 SecurityConfig 파일에서 필터 체인 빈을 등록해주는 부분입니다. 부분부분 살펴보겠습니다.
.httpBasic().disable()
.csrf().disable()
일단 httpBasic()은 스프링이 기본 제공하는 인증 필터입니다. 저는 토큰 기반의 인증을 할 것이므로 해당 필터를 disable() 메서드를 호출하여 비활성화 시켜줍니다. 또한 crsf 필터 역시 비활성화 시켜줍니다.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
토큰 기반의 인증에서는 세션저장소를 필요로 하지 않습니다. Session 생성 정책을 STATELESS로 설정하면 스프링 시큐리티가 세션 저장소를 생성하지 않으며, 기존의 저장소도 사용하지 않습니다.
.authorizeHttpRequests()
.requestMatchers("/**").permitAll()
.anyRequest().authenticated()
이 부분은 인가와 관련된 파트입니다. requestMatchers().permitAll() 메서드를 통해 매개변수에 포함된 url의 인가를 허용합니다. permit("ROLE")를 이용하면 특정 권한을 가진 사용자만 부분적으로 인가를 허용할 수도 있습니다.
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider, corsConfigurationSource()), UsernamePasswordAuthenticationFilter.class)
JwtAuthenticationFilter는 http 요청에 포함된 토큰이 유효한 토큰인지 검증하는 필터입니다. 해당 필터를 UsernamePasswordAuthenticationFilter 다음에 실행되도록 설정합니다.
뒤부분은 예외를 처리를 설정한 부분입니다.
기본적인 Spring Security 설정을 마무리가 되었습니다. 이제는 토큰 관련 기능을 구현할 시간입니다. 토큰 관려해서 필요한 빈은 아래와 같습니다.
- JwtProvider : 토큰 생성/관리/검증에 관한 메서드를 제공합니다.
- CustomUserDetails : 토큰을 포함한 관리자 정보 (스프링 시큐리티가 제공하는 UserDetails를 구현한 클래스입니다)
- JpaUserDetailService : CoustomUserDetails와 관련된 비즈니스 메서드를 제공합니다.
- SignService : 관리자 생성 / 로그인 등의 비즈니스 메서드를 제공합니다.
- SignController : 인증 관련 Http 요청을 처리합니다.
- JwtAuthenticationFilter : Http 요청의 담긴 토큰이 유효한지 검증합니다.
JwtProvider
@RequiredArgsConstructor
@Component
public class JwtProvider {
@Value("${jwt.secret.key}")
private String salt;
private Key secretKey;
// 1 hour
private final long exp = 1000L * 60 * 60;
private final JpaUserDetailService userDetailService;
@PostConstruct
protected void init() {
secretKey = Keys.hmacShaKeyFor(salt.getBytes(StandardCharsets.UTF_8));
}
// 토큰 생성
public String createToken(String account, List<Authority> roles) {
roles.stream().map(role -> "ROLE_" + role);
Claims claims = Jwts.claims().setSubject(account);
claims.put("roles", roles);
Date now = new Date();
return Jwts.builder()
.setSubject(account)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + exp))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailService.loadUserByUsername(getAccountFromToken(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public String getAccountFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(secretKey).build()
.parseClaimsJws(token).getBody()
.getSubject();
}
public String resolveToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
public boolean validateToken(String token) {
try {
// validate bearer
if (!token.substring(0, "Bearer ".length()).equals("Bearer ")) return false;
token = token.split(" ")[1].trim();
Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
JwtProvider는 토큰 인증 기능을 구현하는데 필요한 기본적인 메서드들을 제공합니다.
토큰 생성
public String createToken(String account, List<Authority> roles) {
roles.stream().map(role -> "ROLE_" + role);
Claims claims = Jwts.claims().setSubject(account);
claims.put("roles", roles);
Date now = new Date();
return Jwts.builder()
.setSubject(account)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + exp))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
createToken 메서드는 사용자의 계정정보(account)와 권한리스트(roles)를 받아 토큰을 생성하는 메서드입니다. 사용자 계정(subject), 만료일 및 활성일, 서명 알고리즘 등을 설정하여 토큰을 생성하여 리턴합니다.
토큰 검증
public boolean validateToken(String token) {
try {
// validate bearer
if (!token.substring(0, "Bearer ".length()).equals("Bearer ")) return false;
token = token.split(" ")[1].trim();
Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
validateToken 메서드는 토큰이 유효한지 검증하여 결과값을 불린(boolean)타입으로 리턴합니다. 토큰의 검증은 크게 2 과정으로 이루어집니다.
- 토큰의 데이터 타입이 Bearer 인가?
- 토큰의 만료되지 않았는가?
2가지를 모두 통과하면 유효한 토큰으로 판단하여 true를 리턴합니다.
Authentication 객체 가져오기
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailService.loadUserByUsername(getAccountFromToken(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
인증이 완료되면, 서버에 인증이 됐다는 것을 알리기 위해 Security Context에 Authentication 객체를 등록해주어야 합니다. 그러기 위해서는 token 정보를 기반으로 Authentication 객체를 생성해주어야합니다. getAuthentication은 토큰 문자열을 받아 Authentication 인터페이스를 구현한 UsernamePasswordAuthenticationToken을 생성합니다.
일단 token으로 부터 사용자 정보, userDetails를 받은후 userDetail 객체를 통해 UsernamePasswordAuthenticationToken를 생성합니다.
http 요청으로부터 토큰 가져오기
public String resolveToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
토큰은 문자열로 이루어졌으면 Http 요청에서 Authorization 헤더에 담겨 넘어오게 됩니다. resolveToken 메서드는 요청으로부터 해당 헤더에서 토큰 문자열을 받아 리턴합니다.
CustomUserDetails / JpaUserDetailService
SpringSecurity는 사용자의 핵심 정보를 UserDetails 인터페이스에 담아 처리합니다. CustomUserDetails는 UserDetails 를 구현한 클래스입니다.
public class CustomUserDetails implements UserDetails {
private final Admin admin;
public CustomUserDetails(Admin admin) { this.admin = admin; }
public Admin getAdmin() { return this.admin; }
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.admin.getRoles().stream()
.map(o -> new SimpleGrantedAuthority(o.getName()))
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return this.admin.getPassword();
}
@Override
public String getUsername() {
return this.admin.getAccount();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
CustomUserDetails는 DB에 사용자 정보를 담고있는 Admin 엔티티를 필드로 갖습니다. 또한 각종 getter 메서드를 오버라이딩 합니다. 사용자 아이디/비밀번호/권한(Authorities) 외에는 사용하지 않으므로 모두 true를 리턴하게 구현했습니다.
@Service
@RequiredArgsConstructor
public class JpaUserDetailService implements UserDetailsService {
private final AdminRepository adminRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Admin findAdmin = adminRepository.findOneByAccount(username);
if (findAdmin != null) {
return new CustomUserDetails(findAdmin);
} else {
throw new UsernameNotFoundException("invalid authentication");
}
}
}
JpaUserDetailService 클래스는 UserDetailsService를 구현한 클래스로, CustomUserDetails의 비지니스 메서드를 구현합니다.
loadUserByUsername 유저 계정명으로 부터 UserDetails를 생성해 리턴합니다. 만약에 해당 유저가 DB에 없을 경우 UsernameNotFoundException을 던집니다.
SignService
위에 다룬, JwtProvier나 UserDetailsService는 인증에 필요한 여러 객체나 메서드를 개발한 것이라면 SignService는 실질적인 인증 비지니스 로직을 구현합니다. 그 중에서도 로그인과 회원가입 로직을 구현합니다.
로그인
@Service
@Transactional
@RequiredArgsConstructor
public class SignService {
private final AdminRepository adminRepository;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
public SignResponse login(SignRequest request) throws Exception {
Admin findAdmin = adminRepository.findOneByAccount(request.getAccount());
if (findAdmin == null) {
throw new BadCredentialsException("존재하지 않은 사용자입니다.");
}
if (!passwordEncoder.matches(request.getPassword(), findAdmin.getPassword())) {
throw new BadCredentialsException("잘못된 비밀번호입니다.");
}
return SignResponse.builder()
.id(findAdmin.getId())
.name(findAdmin.getName())
.account(findAdmin.getAccount())
.token(jwtProvider.createToken(findAdmin.getAccount(), findAdmin.getRoles()))
.roles(findAdmin.getRoles().stream().map(Authority::getName).collect(Collectors.toList()))
.status(findAdmin.getStatus())
.build();
}
}
로그인은 크게 2가지 검증을 거쳐 허용되게 됩니다. 먼저, 계정명(account)로 부터 Admin 객체를 불러옵니다. 이때 Admin 객체가 null일 경우, 존재하지 않은 사용자이므로 예외를 던집니다. 사용자가 존재할 경우, 비밀번호가 맞는지 검증합니다. 이때 비밀번호는 암호화 되어있으므로 passwordEncoder 빈을 이용하여 비밀번호를 복호화하여 검증합니다. 검증을 모두 통과하여 로그인에 성공했을 경우, 토큰을 생성하여 사용자 정보에 담아줍니다. 그후 응답 DTO에 담아 클라이언트에게 넘겨줍니다.
로그인 기능에서 제일 중요한 것은 바로 로그인에 성공했을 시, 토큰을 생성해 클라이언트에게 주는 것입니다. 이후 클라이언트는 이 토큰을 이용해 인증을 합니다.
사용자(Admin) 생성
@Service
@Transactional
@RequiredArgsConstructor
public class SignService {
private final AdminRepository adminRepository;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
public SignResponse register(SignRequest request) throws Exception {
try {
Admin admin = Admin.builder()
.name(request.getName())
.account(request.getAccount())
.password(passwordEncoder.encode(request.getPassword()))
.status(Status.NOT_ALLOWED)
.build();
admin.setRoles(Collections.singletonList(Authority.builder().name("ROLE_All").build()));
adminRepository.save(admin);
return new SignResponse(admin);
} catch (Exception e) {
System.out.println(e.getMessage());
throw new Exception("잘못된 요청입니다.");
}
}
}
요청 DTO에 생성할 관리자(Admin)의 정보가 담겨 옵니다. Admin.builder에 해당 정보를 담아 Admin 객체를 생성하여 DB에 저장해줍니다.
JwtAuthenticationFilter
로그인과 회원가입 기능은 SignServicer가 담당했습니다. 하지만 인증 로직은 http 요청이 올때마다 요청을 처리하기 전에 항상 호출되어야 합니다. 그러므로 필터(filter)로 처리가 되어야합니다. JwtAuthenticationFilter가 이 역할을 수행하게 됩니다.
@RequiredArgsConstructor
@CrossOrigin(origins = "http://localhost:5173")
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final CorsConfigurationSource configurationSource;
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = jwtProvider.resolveToken(request);
CorsConfiguration corsConfiguration = this.configurationSource.getCorsConfiguration(request);
if (token != null && jwtProvider.validateToken(token)) {
token = token.split(" ")[1].trim();
Authentication auth = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
JwtAuthenticationFilter는 일단 요청(request)로부터 토큰 문자열을 가져옵니다. 그후 해당 토큰이 유효한지 검사합니다. 만약 토큰 자체가 존재하지 않을 경우에는 추가적인 검사를 하지 않습니다. 토큰이 유효할 경우, 해당 토큰의 정보를 기반으로 Authentication 객체를 생성하여 SecurityContext에 등록합니다. 그러면 이제 서버는 인증이 되었다고 판단하여 요청을 처리합니다.
DTO : SignRequst / SignResponse
다음은 요청과 응답 DTO 입니다. 핵심 사용자 정보를 담고 있으며, 특히 토큰의 문자열을 가지고 있습니다.
@Getter @Setter
@Builder
@AllArgsConstructor
public class SignResponse {
private Long id;
private String name;
private String account;
private String password;
private Status status;
private List<String> roles = new ArrayList<>();
private String token;
public SignResponse(Admin admin) {
if (admin == null) {
return;
}
this.id = admin.getId();
this.name = admin.getName();
this.account = admin.getAccount();
this.password = admin.getPassword();
this.status = admin.getStatus();
this.roles = admin.getRoles().stream()
.map(Authority::getName)
.collect(Collectors.toList());
}
}
@Getter @Setter
public class SignRequest {
private Long id;
private String name;
private String account;
private String password;
}
핵심 기능 매커니즘
모든 구현이 마무리 되었습니다. 이제 핵심 기능들이 어떤 비지니스 로직을 거치는지 살펴보겠습니다.
로그인
- 계정명과 비번이 포함된 Http 요청이 서버로 전달
- SignService는 계정명과 비번이 유효한지 검사, 계정명에 해당하는 사용자정보와 토큰을 발급해 클라이언트에게 전달
- 클라이언트는 해당 토큰을 이용하여 http 요청을 수행
인증
- 클라이언트는 Authentication 헤더에 토큰 문자열을 포함하여 Http 요청을 서버에 전달
- JwtAuthenticationFilter는 http 요청으로 부터 토큰 문자열 가져와 해당 토큰이 유효한지 검사
- 유효한 토큰일 경우 Authentication 객체를 생성해 SecurityContext에 등록
- 서버는 인증된 요청으로 판단, http 요청을 처리
마무리
인증 관련 기능을 구현하는 것을 마무리했습니다. 사실 인증을 구현하는데는 정말 많은 시간이 걸렸습니다;;
특히, 인가파트에서 참 많은 문제가 일어났었습니다. 해당 과정은 아래 포스팅을 참고하시면 됩니당...
[[HTTP 인증 요청시 서버 필터를 거치는 과정에서 인증이 사라지는 문제]](블로드 업로드시 수정)
이제 드디어 개발이 완료됐습니다!! 이제는 서버와 클라이언트를 배포하여 사용자가 사용할 있는 애플리케이션으로 개발해야합니다
'프로젝트 이야기 > 물품 지급앱' 카테고리의 다른 글
5. 클라이언트 배포 회고 (feat. hot-reload) (0) | 2023.05.18 |
---|---|
4. 서버 배포 (0) | 2023.05.18 |
2. 핵심 기능 구현 (2) - REST API 설계 (0) | 2023.05.18 |
1. 핵심 기능 구현(1) - 도메인 설계 (0) | 2023.05.18 |
0. 시작하기 (0) | 2023.05.18 |