Auth (Authentication & Authorization)

Springboot-Angular-JWT 기반 Auth 구현 - 로그인 흐름

kellis 2020. 10. 12. 10:34

지금까지 Session을 기반으로 A/A 기능을 구현하기 위해 두 가지 방법을 이용했었습니다.

1. SpringBoot + Spring Interceptor + Session 기반의 A/A기능 구현(Interceptor를 이용한 Auth 구현)

2. SpringBoot + Spring Security + Session 기반의 A/A기능 구현(Spring Security를 이용한 Auth 구현)

 

그리고 지난 포스트(JWT란?)에서 Session 대신 JWT를 이용하여 A/A 기능을 구현할 수 있다고 언급한 바 있습니다.

따라서 이 글에서는 JWT를 이용해 A/A기능을 구현하려 합니다. Session을 이용했을 때와는 달리 Client application(Node.js + Angular)와 Api server(Springboot)를 분리했는데, 이 방식이 JWT의 장점을 명확히 보여줄 수 있기 때문입니다.

 

인증(Authentication)의 흐름은 다음과 같습니다.

[로그인 요청]

1. Client application는 Api server로 로그인 요청을 합니다.

2. Api server는 로그인 요청 정보의 ID/PW가 올바른지 체크하고, 올바르다면 JWT를 응답/전달합니다. (올바르지 않다면, 401 UNAUTHORIZED를 응답합니다.)

3. Client application에서는 응답받은 JWT를 LocalStorage에 저장합니다. 그리고 JWT를 디코딩해 User 상세 정보를 공유 변수에 저장합니다. (Angular에서는 Service의 필드로 저장할 수 있습니다.)

 

[발급받은 JWT를 기반으로, 인증을 필요로하는 요청]

1. Client application에서는 LocalStorage에 저장한 JWT를 Request header에 추가하여 요청합니다.

2. Api server는 해당 요청이 인증을 필요로 하는 요청이라고 판단하면, Request header를 읽어 JWT가 존재하는지/유효한지 판단합니다. 존재/유효하지 않다면 401 UNAUTHORIZED를 발생시킵니다.

3. Client application가 요청에 대해 401 UNAUTHORIZED를 응답받으면, 로그인 화면으로 이동시킵니다.

 


[로그인 요청]

1. Client application는 Api server로 로그인 요청을 합니다.

export class LoginComponent implements OnInit {
  constructor(
    private _http: HttpClient
    , private router : Router
    , private jwtService : JwtService
    , private userService : UserService) {
  }
  ngOnInit() {
  }
  
  login(userId, password){
    const user:User = new User();
    user.userId=userId;
    user.password=password;
  
    this._http.post("http://localhost:8080/issueToken", user).pipe(
      tap((res :any) => {
        localStorage.setItem("AUTH_TOKEN", res.data);
        this.userService.loginUser = this.jwtService.decodeToUser(res.data);
      })
  
    ).subscribe(res =>{
      this.router.navigate(['main']);
    });
  }
}
  • 16라인: 로그인 폼에 입력한 유저 정보를 확인하기 위해 Api server(localhost:8080)의 issueToken api로 요청합니다. 

 

2. Api server는 로그인 요청 정보의 ID/PW가 올바른지 체크하고, 올바르다면 JWT를 응답/전달합니다.

issueToken api의 구현부를 살펴보겠습니다. 

@RestController
public class LoginController {
  
    @Autowired
    private JwtService jwtService;
  
    @Autowired
    private UserService userService;
  
    @PostMapping("issueToken")
    public ResponseEntity<ResponseMessage> issueToken(@RequestBody User user) {
        String token = null;
        User loginUser = userService.getUser(user);
        if (loginUser != null) {
            token = jwtService.createLoginToken(loginUser);
        }
  
        return token != null ? ResponseEntity.ok().body(new ResponseMessage(null, token, true))
                : new ResponseEntity<>(HttpStatus.UNAUTHORIZED);
    }
}
  • 13라인 : Client server로부터 입력받은 user 정보를 기반으로 DB로부터 조회합니다. 
  • 14~16라인 : 조회 결과, 존재/유효한 user가 있다면 token을 생성합니다. 
  • 18라인 : token 생성 여부에 따라 ResponseEntity를 응답해줍니다. 생성된 token이 있을 경우, token 값도 함께 전송합니다. (ResponseMessage는 편의상 만든 DTO로, 모든 ResponseBody의 타입을 일치시키기 위해 만들었습니다.)

 

3. Client application에서는 응답받은 JWT를 LocalStorage에 저장합니다. 그리고 JWT를 디코딩해 User 상세 정보를 공유 변수에 저장합니다. (Angular에서는 Service의 필드로 저장할 수 있습니다.)

[로그인 요청]-1 과 같은 코드입니다. 

this._http.post("http://localhost:8080/issueToken", user).pipe(
      tap((res :any) => {
        localStorage.setItem("AUTH_TOKEN", res.data);
        this.userService.loginUser = this.jwtService.decodeToUser(res.data);
      })
  
    ).subscribe(res =>{
      this.router.navigate(['main']);
    });
  • 3라인 : res.data는 Api server에서 응답해준 token을 의미합니다. 이 token을 AUTH_TOKEN 키로 localStorage에 저장합니다. localStorage에 저장하는 이유는 애플리케이션 리로딩 시에도 token 데이터를 유지하기 위함입니다. 공유 변수에 저장하면, 애플리케이션 리로딩 시에 메모리에서 제거됩니다.
  • 4라인 : token을 디코딩해 user 상세 정보를 userService의 loginUser(공유 변수)에 저장합니다. Angular에서 공유 변수를 만드는 방법 중 하나가 Service의 필드를 생성하는 것입니다. 이 외에 ngrx로 상태를 관리하는 방법도 있지만, 여기서는 간단하게 Service의 필드를 생성하는 방법을 이용했습니다. 

[발급받은 JWT를 기반으로, 인증을 필요로하는 요청]

1. Client application에서는 LocalStorage에 저장한 JWT를 Request header에 추가하여 요청합니다.

모든 Request의 header에 추가하는 것은 반복적인 작업이므로 HttpInterceptor를 구현하겠습니다.

@Injectable()
export class RequestInterceptor implements HttpInterceptor {
  private user: User;
  constructor(private router : Router){
  
  }
  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    let requestHeaders = request.headers;
    requestHeaders = requestHeaders.append('Content-Type', 'application/json');
    if (localStorage.getItem('AUTH_TOKEN')) {
      requestHeaders = requestHeaders.append('AUTH_TOKEN', localStorage.getItem('AUTH_TOKEN'));
    }
  
    const modified = request.clone({ headers: requestHeaders });
    return next.handle(modified).pipe(
      catchError(errRsp => {
        if (errRsp instanceof HttpErrorResponse) {
            console.log(errRsp.status);
            if(errRsp.status == 401){
              if(confirm(errRsp.error.message)){
                localStorage.removeItem('AUTH_TOKEN');
                this.router.navigate(['login']);
              }
            }else{
              alert(errRsp.error.message);
            }
        }
        return EMPTY;
      })
    );
  }
}
  • 10~12라인 : localStorage에 AUTH_TOKEN 키가 있는지 확인하고, 있으면 header에 추가해줍니다. 위의 작업을 통해, 인증된 사용자의 요청이라면 항상 request header에 token값이 추가될 것입니다. 

 

2. Api server는 해당 요청이 인증을 필요로 하는 요청이라고 판단하면, Request header를 읽어 JWT가 존재하는지/유효한지 판단합니다. 존재/유효하지 않다면 401 에러를 발생시킵니다.

해당 요청이 인증을 필요로 하는 요청인지, 그렇다면 Request header에 token값이 존재하는지, 그리고 그 값이 유효한지 판단해서 요청을 처리해야 합니다. Api server도 Client application처럼 모든 요청에 대해 일괄적으로 처리하기 위해 HandlerInterceptorAdapter를 구현했습니다. 

@Configuration
public class LoginInterceptor extends HandlerInterceptorAdapter {
    private static final String ADMIN = "ADMIN";
  
    @Autowired
    private JwtService jwtService;
  
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;
  
            final String token = request.getHeader("AUTH_TOKEN");
            if (hm.hasMethodAnnotation(LoginRequired.class) && (token == null || !jwtService.isValidToken(token))) {
                throw new AuthenticationException("접근 권한이 없습니다.");
            }
            if (hm.hasMethodAnnotation(AdminOnly.class) && !jwtService.getUser(token).getAuthority().contains(ADMIN)) {
                throw new AuthorizationException();
            }
        }
        return super.preHandle(request, response, handler);
    }
}
  • 14~17라인 : 해당 요청이 인증을 필요로 하는 요청인지에 대한 판단은 요청에 매핑된 Controller에 LoginRequired annotation이 추가되어 있는지 확인하는 것입니다. (LoginRequired는 custom annotation입니다.) 인증을 필요로 하는 요청임에도 token값이 존재/유효하지 않으면 AuthenticationException을 발생시킵니다. 

 

AuthenticationException이 발생하더라도, 앞서 언급했던 ResponseMessage 타입으로 응답하기 위해 그리고 HttpResponse의 status를 401로 지정하기 위해 ControllerAdvice를 이용했습니다.

@ControllerAdvice
public class ExceptionController {
    @ExceptionHandler({ AuthenticationException.class})
    public ResponseEntity<ResponseMessage> authException(Exception e) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ResponseMessage(e.getMessage()));
    }
  
}

3. Client application이 요청에 대해 401에러를 응답받으면, 로그인 화면으로 이동시킵니다.

앞서 AuthenticationException이 발생하면 401에러를 발생시키기로 했으므로, 이에 맞춰 Client server 코드를 작성합니다.

if(errRsp.status == 401){
    if(confirm(errRsp.error.message)){
          localStorage.removeItem('AUTH_TOKEN');
          this.router.navigate(['login']);
    }
}

 

위와 같이 인증(Authentication)을 구현했는데, 여기에는 몇 가지 문제&미구현 건이 있습니다. 

- 권한(Authorization)에 대한 처리가 없습니다.

- 현재 JWT는 로그인한 시점에 생성됩니다. 그리고 그 시점에 token의 만료 시각이 정해져 버립니다. 일반적으로 사용자의 액션이 있으면 만료 시각은 액션이 일어난 시각으로부터 재할당되어야 합니다. 이는 refresh token으로 보완할 수 있습니다.

 

이 문제는 다음 포스트에서 다루도록 하겠습니다.