// 에드센스


1. 구현하고자 하는 것

 

인증은 Firebase에서,

인가는 Spring Security로,

구성하려고 한다.

이후 Controller에서 @PreAuthorize를 통해서 요청을 제한하고자 한다.

즉, Firebase에서 발급한 JWT를 Spring Security에서 사용하기.

 

인증 - 유저가 누구인지 확인하는 절차

인가 - 유저가 요청한 요청(request)을 실행할 수 있는지를 확인하는 절차

 

 

 

 


2. 현재까지 구현된 것

nodejs로 구현한 회원가입 로직에 firebase-admin를 사용하여 Firebase에 유저 생성 및 권한등록을 해주었다.

 

 

import * as admin from 'firebase-admin';

export const createFirebaseUser = (email: string, password: string, name: string) => {
  return admin.auth().createUser({
    email: email,
    password: password,
    displayName: name,
    disabled: false,
  });
};

firebase-admin 라이브러리로 firebase에 새로운 user를 등록해 주었고

 

 

import * as admin from 'firebase-admin';

export const setRole = (uid: string, role: string) => {
  let claims = {
      group: role,
  };
  admin.auth().setCustomUserClaims(uid, claims)
};

firebase-admin 라이브러리로 { group: role } 이라는 customUserClaim을 생성해주었다.

이것 자체로 권한으로써의 역할을 하진 않고 그저 사용자 설정 클레임일 뿐이다.

 

여기까지가 현재 진행 상태였고,

우리의 목표는 Spring에서 이 클레임을 사용자의 ROLE로 인식되게 하는 것이다.

 

 

 

 

 

 


3. JwtFilter 구현

    @PostMapping("")
    @PreAuthorize("hasAnyRole('admin', 'partner')")
    fun postEvent(@Valid @RequestBody req: PostEventRequest): ResponseEntity<Any> {
        println("postEvent")
        return ResponseEntity.status(201).body(object {
            val data = eventService.createOneEvent(req)
        })
    }

이렇게 컨트롤러에 @PreAuthorize 어노테이션을 통해서 사용자의 접근을 걸러낼 수 있어야한다.

저 컨트롤러로 들어가는 request에서 권한을 어떻게 추출하여 걸러낼 수 있을까?

 

  1. Firebase JWT에서 group을 추출하여 role을 얻어낸다.
  2. 이 role을 UserDetails 객체를 만들고 넣어준다.
  3. UserDetails 객체를 SecurityContextHolder에 등록해준다.

이 로직을 Filter로 구현하여 Filter를 등록해주자.

 

참고로, @PreAuthorize 안에서 다양한 표현식을 사용할 수 있다.

https://docs.spring.io/spring-security/site/docs/3.0.x/reference/el-access.html#el-common-built-in

 

15. Expression-Based Access Control

There are some built-in expressions which are specific to method security, which we have already seen in use above. The filterTarget and returnValue values are simple enough, but the use of the hasPermission() expression warrants a closer look. The Permiss

docs.spring.io

 

 

1. Firebase JWT로부터 Custom Claim 추출하기

val accessToken = request.getHeader("Authorization").split(" ")[1]

JWT 토큰이 Header에 Authorization: Bearer {jwt token} 형식으로 요청되어지기에 공백을 기준으로 뒷 덩어리를 가져온다.

 

 

 

2. JWT Token 검증하기

val decodedToken: FirebaseToken = FirebaseAuth.getInstance().verifyIdToken(accessToken)
val uid: String = decodedToken.uid
val role: String = decodedToken.claims["group"] as String

Firebase Admin를 통해서 verify한다.

시간이 만료되었거나 잘못된 토큰일 경우 FirebaseAuthException이나 IllegalArgumentException을 뱉는다.

 

 

 

3. SecurityContextHolder에 UserDetails 객체를 생성하여 등록하기

val userDetails: UserDetails =
    User.builder().username(uid).authorities(SimpleGrantedAuthority(role)).roles(role).password("").build()
val authentication = UsernamePasswordAuthenticationToken(
    userDetails, null, userDetails.authorities
)
SecurityContextHolder.getContext().authentication = authentication

 

 

위 3단계를 합치고 예외 몇 가지를 지정해준 JwtFilter 코드는 다음과 같다.

class JwtFilter: OncePerRequestFilter() {

    @Throws(ServletException::class, IOException::class)
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        try {
            val accessToken =
                request.getHeader("Authorization").split(" ")[1]
            val decodedToken: FirebaseToken = FirebaseAuth.getInstance().verifyIdToken(accessToken)
            val uid: String = decodedToken.uid
            val role: String = decodedToken.claims["group"] as String
            val userDetails: UserDetails =
                User.builder().username(uid).authorities(SimpleGrantedAuthority(role)).roles(role).password("").build()
            val authentication = UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.authorities
            )
            SecurityContextHolder.getContext().authentication = authentication
        } catch (e: FirebaseAuthException) {
            response.status = HttpStatus.SC_UNAUTHORIZED
            response.contentType = "application/json"
            response.writer.write("{\"code\":\"INVALID_TOKEN\", \"message\":\"" + e.message + "\"}")
            return
        } catch (e: IllegalArgumentException) {
            response.status = HttpStatus.SC_UNAUTHORIZED
            response.contentType = "application/json"
            response.writer.write("{\"code\":\"INVALID_TOKEN\", \"message\":\"" + e.message + "\"}")
            return
        } catch (e: NoSuchElementException) {
            response.status = HttpStatus.SC_UNAUTHORIZED
            response.contentType = "application/json"
            response.writer.write("{\"code\":\"USER_NOT_FOUND\"}")
            return
        } catch (e: NullPointerException) {
            response.status = HttpStatus.SC_BAD_REQUEST
            response.contentType = "application/json"
            response.writer.write("{\"code\":\"AUTHORIZATION_HEADER_NOT_FOUND\"}")
            return
        } catch (e: Exception) {
            e.printStackTrace()
            return
        }
        filterChain.doFilter(request, response)
    }
}

 

 

 

 


4. Filter 등록

이렇게 구현한 Filter를 Spring Security의 Filter Chain에 등록해준다.

 

@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)
class WebSecurityConfig: WebSecurityConfigurerAdapter(){

    @Throws(java.lang.Exception::class)
    override fun configure(web: WebSecurity) {
        web.ignoring()
            .antMatchers("/")
            .antMatchers("/ping")
    }

    @Throws(Exception::class)
    override fun configure(http: HttpSecurity) {
        http
            .cors().disable()
            .csrf().disable()
            .authorizeRequests()
            .antMatchers("/v1/**").authenticated().and()
            .addFilterBefore(
                JwtFilter(),
                UsernamePasswordAuthenticationFilter::class.java
            )
            .exceptionHandling()
            .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
    }
}

 

@PreAuthorize가 동작하지 않으면 다음을 추가해주자

@EnableGlobalMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)

 


5. 동작확인

만료된 JWT Token으로 접근을 시도하면 401을 뱉으며 잘 쳐내는 것을 볼 수 있다.

 

권한에 맞지 않는 api에 접근을 시도하면 403을 뱉으며 잘 쳐내는 것을 볼 수 있다.

 

 

 

 

 

 

참고:

 

@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring()
    	.antMatchers("/")
        .antMatchers("/ping");
}

@Override
public void configure(HttpSecurity http) throws Exception {
     http.csrf().disable()
        .authorizeRequests()
        .antMatchers("/v1/**").authenticated();
}

개발하며 위처럼 파라미터가 다른 두 configure를 재정의해서 사용할 일이 있었다.

 

 

HttpSecurity 패턴은 보안처리

WebSecurity 패턴은 보안예외처리 (정적리소스나 health check)

 

WebSecurity 패턴은 HttpSecurity패턴이 정의되어야 사용할 수 있다.

 

 

 

configure (WebSecurity)

  • antMatchers에 파라미터로 넘겨주는 endpoints는 Spring Security Filter Chain을 거치지 않기 때문에 '인증' , '인가' 서비스가 모두 적용되지 않는다.
  • 또한 Security Context를 설정하지 않고, Security Features(Secure headers, CSRF protecting 등)가 사용되지 않는다.
  • Cross-Site-Scripting(XSS), content-sniffing에 대한 endpoints 보호가 제공되지 않는다.
  • 일반적으로 로그인 페이지, public 페이지 등 인증, 인가 서비스가 필요하지 않은 endpoint에 사용한다.

 

configure (HttpSecurity)

  • antMatchers에 있는 endpoint에 대한 '인증'을 무시한다.
  • Security Filter Chain에서 요청에 접근할 수 있기 때문에(요청이 security filter chain 거침) 인증, 인가 서비스와 Secure headers, CSRF protection 등 같은 Security Features 또한 사용된다.
  • 취약점에 대한 보안이 필요할 경우 HttpSecurity 설정을 사용해야 한다.

 

 

 

 

참고:

더보기

거의 한달만에 글을 쓴다. 그간 참 많은 일이 있었다. 

취업을 했고 킥보드를 타다가 응급실도 갔다. 너무 정신없는 시간이었다고 변명을 해본다ㅠ

요즘은 TypeScript+Express를 통한 개발을 하고있는데 Express 사용시 헤더의 설정을 통하여 웹 취약점으로부터 서버를 보호해주는 보안 모듈인 Helmet이라는 것을 알게 됐고 간단하게 정리해보겠다.

(헤더에 씌운다고해서 이름이 헬멧인 것이 너무 귀엽다. 내 머리에 헬멧을 썻어야했는데 익스프레스에만 씌우고있다 아ㅋㅋ)

 


1. 사용법

그냥 한 줄만 추가해 주면 된다.

npm install helmet

헬멧을 설치하고,

 

 

const helmet = require('helmet');
const express = require('express');
const app = express();

app.use(helmet());

이렇게 붙혀주기만 하면 된다.

 

 

 

 


2. 무엇을 보호해주는가?

세부적인 미들웨어 함수들을 포함하고있는 헬멧은 다음과 같은 기능이 있다.

 

1. csp

csp는 Content-Security-Policy이다. 브라우저에서 사용하는 컨텐츠 기반의 보안 정책으로 XSS나 Data Injection, Click Jacking 등 웹 페이지에 악성 스크립트를 삽입하는 공격기법들을 막기 위해 사용된다.

 

2. hidePoweredBy

헤더에서 X-Powered-By를 제거한다. 이는 서버에 대한 정보를 제공해주는 역할로 나 같은 경우는 이 영역에 Express라고 표기됨을 확인할 수 있었다. 이 정보는 악의적으로 활용될 가능성이 높기에 헬멧을 통해서 제거해 주는 것이 좋다.

 

3. HSTS

HTTP Strict Transport Security의 약자로 웹 사이트에 접속할 때 강제적으로 HTTPS로 접속하게 강제하는 기능이다. 

사용자가 특정 사이트에 접속할 때 해당 사이트가 HTTPS를 지원하는지, 하지 않는지를 미리 모르는 경우가 대부분이다. 그렇기에 브라우저는 디폴트로 HTTP로 먼저 접속을 시도한다. 이때 HTTPS로 지원되는 사이트였다면 301Redirect나 302 Redirect를 응답하여 HTTPS로 다시 접속하도록 한다.

 

하지만 이때 해커가 중간자 공격을 하여, 중간에 프록시 서버를 두고

[나] <-> [해커] 사이에서는 HTTP 통신을 하고 [해커] <-> [웹사이트] 사이에선 HTTPS 통신을 한다면,

우리의 개인정보가 HTTP 프로토콜을 통해 해커에게로 전해지는 참사가 일어난다.

이러한 공격을 SSL Stripping이라고 하며 이런 공격을 방지하기 위해 HSTS를 설정한다.

 

4. IeNoOpen

IE8 이상에 대해 X-Download-Options를 설정한다. 이 옵션은 8 버전 이상의 인터넷 익스플로러에서 다운로드된 것들을 바로 여는 것 대신 저장부터 하는 옵션이다. 사용자는 저장부터 하고 다른 응용프로그램에서 열어야 한다.

 

5. noCache

클라이언트 측에서 캐싱을 사용하지 않도록 하는 설정이다. 

 

6. noSniff

X-Content-Type-Options 를 설정하여 선언된 콘텐츠 유형으로부터 벗어난 응답에 대한 브라우저의 MIME 스니핑을 방지한다. MIME이란 Multipurpose Internet Mail Extensions의 약자로 클라이언트에게 전송된 문서의 다양성을 알려주기 위한 포맷이다. 브라우저는 리소스를 내려받을 때 MIME 타입을 보고 동작하기에 정확한 설정이 중요하다.

 

MIME 스니핑이란 브라우저가 특정 파일을 읽을 때 파일의 실제 내용과 Content-Type에 설정된 내용이 다르면 파일로 부터 형식을 추측하여 실행하는 것인데, 편리함을 위한 기능이지만 공격자에게 악용 될 가능성이 있다.

 

7. frameguard

 X-Frame-Options 헤더를 설정하여 클릭재킹에 대한 보호를 제공한다.

클릭재킹이란 사용자가 자신이 클릭하고 있다고 인지하는 것과 다른 것을 클릭하도록 하여 속이는 해킹 기법이다. 속이기 위해 보이지 않는 레이어에 보이지 않는 버튼을 만드는 방법이 있다.

 

8. xssFilter

xss필터는 xss필터.

 

 

 

 

 

 

 

 

 

 

 

참고:

더보기

+ Recent posts