Backend 개발자/Springboot

스프링 시큐리티 Springboot gradle 설정 회원 코드 로그인 로그아웃 회원가입 회원조회 Jwt Token 살펴보기 Spring Secruit 2편

by 앵과장 2022. 4. 5. 11:11
반응형

안녕하세요

앵과장입니다.

 

이전 블로그에서는 Spring Secruit 를 구현하기위해서 필요한 내용들에 대해서 개념적인 부분과 필요한 부분들에 대해서 정의해보았습니다.

농담처럼 왜필요한지 동기부여를 적어봤는데!!

 

Spring Secruit 를 쓰는 주된이유는 아무래도 여러 filter 기능 그리고 라이프 사이클 때문입니다.

모든것들을 직접 구현 할수는 있지만 URL 호출 전 호출 이 후 로그인전 로그인 이후 오류처리 등등등 여러부분에 End Point를 직접 구현한다는 것은 쉽지도 안을뿐더라 순서보장이나 안정성에 대한 부분들 때문이라도 쓰는것이 좋은 방법입니다.

 

또한 OAuth, JWT, SNS 로그인 등 여러가지 기능구현에 많은 부분들을 보다 쉽게 개발하기 용이하게 해줍니다.

 

Spring Boot + Jpa + Secruit + Jwt + gradle + Swagger API
기능
- 회원 가입
- 회원 로그인(인증)
- 회원 로그아웃
build.gradle
Gradle 7.2 버전 기준입니다.

dependencies는 기능에 필요한 라이브러리들만 추가하였습니다.

plugins {
    id 'org.springframework.boot' version '2.5.6'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.idus'
version = '0.0.1'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    implementation('com.h2database:h2:1.4.196')
    runtimeOnly 'mysql:mysql-connector-java'

    implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2'

    testImplementation 'org.springframework.security:spring-security-test'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    implementation "io.springfox:springfox-boot-starter:3.0.0"
    implementation "io.springfox:springfox-swagger-ui:3.0.0"
}

test {
    useJUnitPlatform()
}

 

Security Config 설정 코드

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
	@Autowired
	private TokenProvider tokenProvider;
	@Autowired
	private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
	@Autowired
	private JwtAccessDeniedHandler jwtAccessDeniedHandler;

	@Bean
	public PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}
}

 

스프링 시큐리티 별도설정없이 구동해보기

Security를 별도의 설정을 하지 않고 구동 후 브라우저를 접근하시면 

 

ID, Password 를 넣게되는 form 창을 볼수 있습니다.

URL 패턴을 인지하고 Filter를 통해서 기능을 제공하기 때문입니다.

 

회원 API만 제공하는 Server를 구현한다면 불필요한 부분들을 제어 해야합니다.

URL에 대한 접근제어를 하기위해서는 Security Configuration 설정해야합니다.

 

Spring Security configuration 
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
	@Autowired
	private TokenProvider tokenProvider;
	@Autowired
	private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
	@Autowired
	private JwtAccessDeniedHandler jwtAccessDeniedHandler;

	@Bean
	public PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}

    @Override
	protected void configure(HttpSecurity http) throws Exception {
		http.csrf().disable()

				.exceptionHandling()
				.authenticationEntryPoint(jwtAuthenticationEntryPoint)
				.accessDeniedHandler(jwtAccessDeniedHandler)

				.and()
				.headers()
				.frameOptions()
				.sameOrigin()

				.and()
				.sessionManagement()
				.sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeRequests()
                .antMatchers("/signin", "/signup", "/reissuance", "/h2/**", "/swagger-ui/**", "/swagger-resources/**").permitAll()
                .anyRequest().authenticated()

				.and()
				.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);
	}

특정 리소스 접근 허용 또는 특정 권한을 가진 사용자만 접근을 가능하게 할수 있으며 코드에서 보는것처럼 

체이닝 형태로 구성할수 있습니다.

 

authenticationEntryPoint

인증절차를 처리하기 위한 용도 입니다. 

인증과정에서 실패하거나 인증해더를 보내지 않게되는경우 보통 401(UnAuthorized)응답값을 받게되는데 이를처리해주는 로직이 바로 

"authenticationEntryPoint" 인터페이스 입니다.

.authenticationEntryPoint(jwtAuthenticationEntryPoint)
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setContentType( MediaType.APPLICATION_JSON_VALUE );

        ApiErrorResponse apiErrorResponse = new ApiErrorResponse();
        apiErrorResponse.setErrorMessage(authException.getMessage());
        apiErrorResponse.setErrorCode(HttpServletResponse.SC_UNAUTHORIZED);

        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, objectMapper.writeValueAsString(apiErrorResponse));
    }
}

 

accessDeniedHandler

서버에 요청을 할때 액세스가 가능한지 권한을 체크후 엑세스 할수 없는 요청을 했을때 동작하게됩니다.

.accessDeniedHandler(jwtAccessDeniedHandler)
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setContentType( MediaType.APPLICATION_JSON_VALUE );

        ApiErrorResponse apiErrorResponse = new ApiErrorResponse();
        apiErrorResponse.setErrorMessage(accessDeniedException.getMessage());
        apiErrorResponse.setErrorCode(HttpServletResponse.SC_FORBIDDEN);

        response.sendError(HttpServletResponse.SC_FORBIDDEN, objectMapper.writeValueAsString(apiErrorResponse));
    }
}

 

sessionCreationPolicy

Jwt 토큰방식을 사용할때 Session 정책 설정하는 방식입니다.

4가지의 상수가 존재합니다

 

sessionCreationPolicy.ALWAYS : 스프링 시큐리티가 항상 Session 생성

sessionCreationPolicy.IF_REQUIRED : 스프링 시큐리티가 필요시 생성 (기본)

sessionCreationPolicy.NEVER : 스프링 시큐리티가 생성하지 않지만 기존에 존자하면 사용

sessionCreationPolicy.STATELESS : 스프링 시큐리티가 생성하지도 않고 기존것을 사용하지도 않음 

 

JWT 사용시 아래코드 상수값을 사용합니다.

.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)

 

antMatchers

아래 코드처럼 리소스를 정의하고 .permitAll() 을 선언하면 

리소스의 접근을 인증절차 없이 허용한다는 의미입니다.

 

로그인, 로그아웃 뿐만아니라 Swagger API로 노출되는 URI는 인증이 패스되어야합니다.

h2 database를 사용했기 때문에 h2 관련 리소스도 인증 패스되도록 설정하였습니다.

.antMatchers("/signin", "/signup", "/reissuance", "/h2/**", "/swagger-ui/**", "/swagger-resources/**").permitAll()
hasAnyRole 

사용한다면 특정 Role를 가진 사용자만 접근을 허용할수도 있습니다.

antMatchers("/admin/**").hasAnyRole("ADMIN")

 

addFilterBefore

지정된 필터 앞에 커스텀 필터를 추가 UsernamePasswordAuthenticationFilter 보다 먼저 실행하게됩니다.

JWTFilter ,UsernamePasswordAuthenticationFilter를 실행하게됩니다.

addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);

JWT 사용을 위한 추가
JwtFilter, TokenProvider, JwtAuthenticationEntryPoint

JwtFilter 를 사용하기 위해서 doFilter를 addFilterBefore 에 설정합니다.

.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    private final TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        String jwt = tokenProvider.resolveToken(request);

        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
}

JwtFilter에는 TokenProvider 구현체가 존재하며 Jwt Token 에 대한 정책 및 비지니스 로직이 구현되어있습니다.

public class TokenProvider {
    public static final String ISSUER = "IDUS";
    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일
    public static final String UID = "uid";
    public static final String AUTHORIZATION_HEADER = "Authorization";

    @Autowired
    private UserService userService;

    private final Key key;

    public TokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public TokenResponse generateToken(Authentication authentication) {
        Map<String, Object> header = new HashMap<>();
        header.put("typ", "JWT");
        header.put("alg", "HS256");

        long now = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();

        UserDetails userDetails = (UserDetails) authentication.getPrincipal();

        TokenResponse tokenResponse = this.generateAccessToken(authentication);

        Claims claims = Jwts.claims();
        claims.setIssuer(ISSUER);
        claims.put(UID, ((PrincipalDetails) userDetails).getUser().getId());
        claims.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME));

        String refreshToken = Jwts.builder()
                .setHeader(header)
                .setIssuer(claims.getIssuer())
                .setIssuedAt(claims.getIssuedAt())
                .setExpiration(claims.getExpiration())
                .setClaims(claims)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        tokenResponse.setRefreshToken(refreshToken);

        return tokenResponse;
    }

    public TokenResponse generateAccessToken(Authentication authentication) {
        Map<String, Object> header = new HashMap<>();
        header.put("typ", "JWT");
        header.put("alg", "HS256");

        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();

        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);

        UserDetails userDetails = (UserDetails) authentication.getPrincipal();

        Claims claims = Jwts.claims();
        claims.setIssuer(ISSUER);
        claims.put(AUTHORITIES_KEY, authorities);
        claims.put(UID, ((PrincipalDetails) userDetails).getUser().getId());
        claims.setExpiration(accessTokenExpiresIn);

        String accessToken = Jwts.builder()
                .setHeader(header)
                .setIssuer(claims.getIssuer())
                .setIssuedAt(claims.getIssuedAt())
                .setExpiration(claims.getExpiration())
                .setClaims(claims)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return TokenResponse.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .build();
    }

    public Authentication getAuthentication(String accessToken) {
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new ApiException(ServiceErrorType.UNAUTHORIZED);
        }

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        User user = userService.getById(Long.parseLong(String.valueOf(claims.get(UID))));

        UserDetails principal = new PrincipalDetails(user);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

 

스프링 시큐리티를 구성하기 위해서는 체이닝 명렁어를 통해서 원하는 형태에 구성을 할수 있습니다.

필터 기능 및 설명이 필요하다면 1편을 참고하세요!!

 

로그인 인증 인가 처리 보안 스프링 시큐리티를 사용하는 이유? Spring Security 1편

안녕하세요 앵과장 입니다. Spring Security 를 알아야 하는 가장 근복적인 이유 와 동기부여 회원 서비스 직접 개발할일이 많지 않을수 있습니다. 매년 3월달은 많은 개발자분들에게 설레임과 분노

angryfullstack.tistory.com

 

 


 

API Server로만 구성한다면 아래정보들은 필요하지 않습니다.

 

아래 코드는 로그인 페이지와 기타 로그인 처리 및 성공 실패 처리를 사용하겠다는 의미입니다.

http.formLogin();  

 

아래코드는 사용자가 따로 만든 로그인 페이지를 사용하려고 할때 설정합니다.

loginPage("/login-page") 

정상적으로 인증성공 했을 경우 이동하는 페이지를 설정합니다.

defaultSuccessUrl("/main")

정상적인증 성공 후 별도의 처리가 필요한 경우 커스텀 핸들러를 생성하여 등록할 수 있습니다.

successHandler(new CustomAuthenticationSuccessHandler("/main"))

failureUrl 은 인증이 실패 했을 경우 이동하는 페이지를 설정합니다.

failureUrl("/login-fail")

나머지 부분들은 Github를 참고하시면 됩니다.

https://github.com/lswteen/idus-security

 

GitHub - lswteen/idus-security: 백패커/아이디어스 과제

백패커/아이디어스 과제. Contribute to lswteen/idus-security development by creating an account on GitHub.

github.com

 

 

Spring Secruit 공식 가이드 Spring.io 정보입니다.

Spring boot 2022.04.04 기준

버전 정보  : 5.6.2

https://spring.io/projects/spring-security#learn

 

Spring Security

Spring Security is a framework that focuses on providing both authentication and authorization to Java applications. Like all Spring projects, the real power of Spring Security is found in how easily it can be extended to meet custom requirements

spring.io

https://github.com/spring-projects/spring-security-samples/tree/5.6.x/servlet/spring-boot/java/hello-security

 

GitHub - spring-projects/spring-security-samples

Contribute to spring-projects/spring-security-samples development by creating an account on GitHub.

github.com

https://docs.spring.io/spring-security/reference/getting-spring-security.html#getting-gradle-boot

 

Getting Spring Security :: Spring Security

As most open source projects, Spring Security deploys its dependencies as Maven artifacts, which allows for first-class Gradle support. The following topics provide detail on how to consume Spring Security when using Gradle. Spring Boot with Gradle Alterna

docs.spring.io