Spring Secuirty 34 MAC 기반 Authentication 프로젝트
포스트
취소

Spring Secuirty 34 MAC 기반 Authentication 프로젝트

이번시간에는 회원가입 + 로그인시 form 로그인 이용 로그인이 완료되면 JWT 발급 이때 사용할 암호화 방식은 MAC 방식으로 진행할것입니다 그리고 로그인이 완료되면 JWT return 해서 JWT 로 통신하는 방법에 대해서 기술하겠습니다

git 소스

https://gitlab.com/kimdongy1000/spring_security_web/-/tree/main_mac_authentication_project?ref_type=heads

사용기술

JDK 11 springboot 2.7.1 jpa spring-security-resource-server lombok spring-web thyleamf bootstrap

maven

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<dependencies>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-security</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-test</artifactId>
		<scope>test</scope>
	</dependency>
	<dependency>
		<groupId>org.springframework.security</groupId>
		<artifactId>spring-security-test</artifactId>
		<scope>test</scope>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-thymeleaf</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-data-jpa</artifactId>
	</dependency>
	<dependency>
		<groupId>com.h2database</groupId>
		<artifactId>h2</artifactId>
		<scope>runtime</scope>
	</dependency>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<version>1.18.30</version>
		<scope>provided</scope>
	</dependency>
</dependencies>

maven 은 간략하게 전달하겠습니다

회원가입부터 회원가입은 생략 (자세한것은 git 소스 참조부탁드립니다)

JpaAuthenticationProviderManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class JpaAuthenticationProviderManager implements AuthenticationProvider {

    @Autowired
    private JpaUSerDetailsService jpaUSerDetailsService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String email = authentication.getName();
        String password = authentication.getCredentials().toString();

        UserDetails user = jpaUSerDetailsService.loadUserByUsername(email);

        if(!passwordEncoder.matches(password ,  user.getPassword())) throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");

        UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.authenticated(user , authentication.getCredentials() , user.getAuthorities());


        return authenticationToken;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

이 부분에서는 JPA 에 등록된 user 데이터를 가져와서 인증이 완료 되었으면 authenticationToken 을 반환합니다 이때 UserDetails 객체는 user 는

JpaUSerDetailsService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class JpaUSerDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        Optional<UserEntity> optionalUser = userRepository.findByUsername(username);

        if(!optionalUser.isPresent()) throw new UsernameNotFoundException(username);


        UserEntity userEntity = optionalUser.get();
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        grantedAuthorities.add(new SimpleGrantedAuthority(userEntity.getAuthority()));
        UserDetails user = new User(userEntity.getUsername() , userEntity.getPassword() ,  grantedAuthorities);

        return user;
    }
}

마찬가지로 UserDetailsSServicer 는 repository 에서 User 객체를 가져오고 username 으로 조회한뒤 있으면 UserDetails 을 일차적으로 반환 위의 JpaAuthenticationProviderManager 에서 비밀번호를 검증하게 됩니다

다시 JpaAuthenticationProviderManager 넘어오게 되면

1
2
3
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.authenticated(user , authentication.getCredentials() , user.getAuthorities());

여기서 이제 인증이 성공이 되었으니 JpaUsernamePasswordSuccessHandler 에서 이제 JWT 토큰을 발급하기 위한 작업을 진행을 합니다

JpaUsernamePasswordSuccessHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class JpaUsernamePasswordSuccessHandler implements AuthenticationSuccessHandler {

    private JwtGenerator jwtGenerator;

    private OctetSequenceKey jwtKeyGenerator;

    public JpaUsernamePasswordSuccessHandler(JwtGenerator jwtGenerator , OctetSequenceKey jwtKeyGenerator){
        this.jwtGenerator = jwtGenerator;
        this.jwtKeyGenerator = jwtKeyGenerator;
    }



    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

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

        String jwtToken;
        try {

            jwtToken = jwtGenerator.jwtGenerator(userDetails , jwtKeyGenerator);
            Cookie cookie = new Cookie("jwtToken" , jwtToken);
            cookie.setPath("/");
            response.addCookie(cookie);
            response.sendRedirect("/main/");


        } catch (Exception e) {
            throw new RuntimeException(e);
        }


    }
}


결국에는 onAuthenticationSuccess 에서 UserDetails 를 받아서 jwtToken = jwtGenerator.jwtGenerator(userDetails , jwtKeyGenerator); 사용해서 jwtToken 을 만들어내고 그것을 Cookie 로 반환을 합니다 (Header 로 못한 이유는 리다이렉시 header 는 같이 넘기지 못하는 이슈가 있어서 Cookie 로 넘깁니다)

JwtGenerator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public class JwtGenerator {

    public String jwtGenerator(UserDetails userDetails , OctetSequenceKey jwtKeyGenerator) throws Exception{

		/*
		* OctetSequenceKey 는 JWK 세트에 속하는 키 중 하나로 대칭키를 표현할때 사용하게 됩니다 
		* JWK Json Web Key 디지털 서명을 위한 키를 나타내는 표준입니다 
		* 암호화와 복호화에 동일한 비밀 키를 사용하는 알고리즘에서 사용되는 키입니다
		*/
        OctetSequenceKey octetSequenceKey =  jwtKeyGenerator; 
		


		/*
        * octetSequenceKey 는 기본적으로 만들때
        * keySize 와 비밀키 및 알고리즘을 지정해서 대칭키 를 만들 수 있습니다
        * KeySize 는 key 길이를 나타내는것으로 spring - security 는 기본적으로 256 이상으로 만들어야 합니다 
        * algorithm 는 암호화 할때 사용하는 어떤 방식의 알고리즘을 사용할지 정하게 됩니다 
        * */
        JWSAlgorithm jwsAlgorithm = (JWSAlgorithm) octetSequenceKey.getAlgorithm();
		String keyId = octetSequenceKey.getKeyID();
        SecretKey secretKey =  octetSequenceKey.toSecretKey();

        List<String>  authorities = userDetails.getAuthorities().stream().map( x-> {return x.getAuthority();}).collect(Collectors.toList());
        authorities.add("EMAIL");
        authorities.add("PROFILE");

		/* JWSHeader 은 JWT 를 만들떄 헤더에 속하는 데이터로 이때는 알고리즘과 를 포함한 값의 길이를 반환합니다
         *
         */
        JWSHeader jwtHeader = new JWSHeader.Builder((JWSAlgorithm) octetSequenceKey.getAlgorithm()).keyID(keyId).build();

		/*
        * JWTClaimsSet 는 payload 를 나타내는것으로 이에 대해서는 앞전에 한번 설명드린적이 있으므로 pass 하겠습니다
        *
        * */
        JWTClaimsSet jwtPayload = new JWTClaimsSet.Builder()
                .subject("user")
                .issuer("httpL//localhost:8080")
                .claim("username" , userDetails.getUsername())
                .claim("authority" , authorities)
                .expirationTime(new Date(new Date().getTime() + 60 * 1000 * 5))
                .build();

		/*
        * 그리고 이 부분이 서명부분이다 MACSigner 부분으로 secretKey 서명을 만들고
		*
        * */
        MACSigner jwsSigner = new MACSigner(secretKey);


		/*
        * JWT 를 서명할때는 헤더와 payload 가 필요함으로 SignedJWT 객체에 첨부후
        *
        * */
        SignedJWT signedJWT = new SignedJWT(jwtHeader , jwtPayload);

		 /*
        * 만든 SecretKey 로 서명을 하게 됩니다
        *
        * */
        signedJWT.sign(jwsSigner);

        String token = signedJWT.serialize();

        return token;
    }
}

JwtGenerator 는 User 정보를 기반으로 JWT 객체를 만드는 역활입니다

JwtKeyGenerator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
public class JwtKeyGenerator {

    @Value("${spring.security.keySize:256}")
    private int keySeize;

    @Value("${spring.security.secretKey:application}")
    private String secretKey;


    @Bean
    public OctetSequenceKey createToken() throws Exception{
        OctetSequenceKey octetSequenceKey = new OctetSequenceKeyGenerator(keySeize).keyID(secretKey).algorithm(JWSAlgorithm.HS256).generate();
        return octetSequenceKey;
    }
}


이 부분이 위에서 JwtGenerator 에서 사용하는 MAC 타입의 JWK 를 만드는 부분입니다 보시다 싶히 keySize 와 알고리즘 그리고 secretKey를 넣어서 생성을 해서 이를 공통적으로 사용을 해야 하기 때문에 이를 Bean 으로 등록을 합니다 이때 value 값은 application.properties 에 저장을 하고 그게 없으면 설정한 디폴트 값을 설정했습니다

JwtAuthenticationFilter

처음에 form 로그인으로 사용자를 찾아서 jwt 를 발급받아서 되돌아오게 되면 이제는 jwt 를 통해서 통신을 할 수 있게끔 만들어야 합니다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private OctetSequenceKey jwtKeyGenerator;

    public JwtAuthenticationFilter(OctetSequenceKey jwtKeyGenerator){
        this.jwtKeyGenerator = jwtKeyGenerator;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {


        /*
        * 쿠키 탐색 쿠키가 없으면 그냥 다음 filter 통과
        * */
        Cookie[]  cookies = request.getCookies();
        if(cookies == null ){
            filterChain.doFilter(request , response);
            return;
        }

        /*
        * 쿠키 탐색을 하면서 우리가 앞에서 넣어주는 쿠를 찾게 됩니다
        *
        * */

        for (Cookie cookie : cookies) {
            String request_cookie_name = cookie.getName();

            if("jwtToken".equals(request_cookie_name)){
                
                /*
                * 쿠키에서 값을 분리 
                * 
                * */
                String token =  cookie.getValue();
                
                try{

                    /* bean 으로 등록한 MAC 기반의 JWK 를 불러와서 넣음 
                     * 
                     * */
                    OctetSequenceKey octetSequenceKey = jwtKeyGenerator;
                    
                    /*
                     *
					 * token 을 parse 로 넣어서 서명을 검토하기 위한 SignedJWT 객체를 만들게 됩니다
                     */
                    SignedJWT signedJWT = SignedJWT.parse(token);
                    
                    /*
                    * Bean 으로 등록한 JWK 를 기반으로 서명이 일치하는지 아닌지 확인을 하기 위해서 
                    * MACVerifier 객체를 만들게 됩니다 
                    * */
                    MACVerifier macVerifier = new MACVerifier(octetSequenceKey.toSecretKey());

                    /*
                    * 그리고 JWT 의 서명부분과 시크릿 key 를 넣은 MACVerifier 를 통해서 verify 를 불러오게 넣게 되면 
                    * true , false 를 반환하게 됩니다 
                    * 
                    * */
                    boolean verify = signedJWT.verify(macVerifier);

                    /*
                    * true 가 되면 여기서 발급한 jwt 가 맞기 때문에 
                    * 이제 인증과정으로 가게 됩니다 
                    * 
                    * */
                    if(verify){
                        
                        /*
                        * 
                        * JWTClaimsSet 으로 값을 분리 
                        * 
                        * */
                        JWTClaimsSet jwtClaimsSet = signedJWT.getJWTClaimsSet();
                        String username = (String)jwtClaimsSet.getClaim("username");
                        List<String> authority = (List<String>) jwtClaimsSet.getClaim("authority");

                        if(username == null) throw new UsernameNotFoundException("username 을 찾을 수 없습니다");
                        if(authority.isEmpty()) throw new RuntimeException("등록된 권한이 없습니다");

                        List<GrantedAuthority> array_authority = authority.stream().map(x -> new SimpleGrantedAuthority(x)).collect(Collectors.toList());

                        /*
                        * JWT 에서 분리한 값에 username , authority 로 새로운 UserDetails 를 만들게 됩니다 
                        * 이때 비밀번호는 없기 때문에 임시 비밀번호를 발급해서 새로운 User 를 넣게 되고 
                        * 
                        * */
                        UserDetails userDetails = new User(username , UUID.randomUUID().toString() , array_authority);
                        
                        /*
                        * 인증객체를 UsernamePasswordAuthenticationToken 넘겨서 인증을 받게끔 위임합니다 
                        * 
                        * */

                        Authentication authenticationToken = new UsernamePasswordAuthenticationToken(userDetails , null , array_authority);
                        
                        /*
                        * 그리고 인증이 완료되면 Authentication 객체를 SecurityContextHolder 심는것으로 끝입니다 
                        * */

                        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

                        filterChain.doFilter(request , response);
                        return;
                    }
					
                }catch(Exception e){
                    throw new RuntimeException(e);
                }
            }
        }
        filterChain.doFilter(request , response);
        return;
    }
}

MainController

1
2
3
4
5
6
7
8
9
10
11
@Controller
@RequestMapping("/main")
public class MainController {

    @GetMapping("/")
    @ResponseBody
    public Authentication mainPage(Authentication authentication){

        return authentication;
    }
}

그러면 로그인 성공시 main 으로 핸들러가 찾아올때 현재 로그인한 상태 이때는 form 로그인 상태가 아닌 JWT 토큰 으로 발급받은 Authentication 타입의 객체입니다

SecurityConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@Configuration
public class SecurityConfig {


    @Autowired
    private OctetSequenceKey keygenerator;


    @Bean
    public SecurityFilterChain securityFilterChain (HttpSecurity http) throws Exception {

        http.authorizeRequests().antMatchers("/" ,
                                                        "/signUp/**" ,
                                                        "/bootStrap/css/**" ,
                                                        "/bootStrap/js/**" ,
                                                        "/resources/css/**" ,
                                                        "/resources/js/**" ,
                                                        "/favicon.ico"

                                                            ).permitAll().anyRequest().authenticated();


        http.formLogin()
                .loginPage("/login/")
                .loginProcessingUrl("/login/")
                .usernameParameter("email")
                .passwordParameter("password")
                .successHandler(authenticationSuccessHandler())
                .permitAll();


        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        http.authenticationProvider(authenticationProvider());

        http.addFilterBefore(jwtAuthenticationFilter(keygenerator) , UsernamePasswordAuthenticationFilter.class);



        http.csrf().disable();

        return http.build();
    }

    @Bean
    public AuthenticationProvider authenticationProvider(){
        return new JpaAuthenticationProviderManager();
    }

    @Bean
    public AuthenticationSuccessHandler authenticationSuccessHandler(){
        return new JpaUsernamePasswordSuccessHandler(jwtGenerator() , keygenerator);
    }
    @Bean
    public JwtGenerator jwtGenerator(){
        return new JwtGenerator();
    }

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter(OctetSequenceKey keygenerator){

        return new JwtAuthenticationFilter(keygenerator);
    }
}

그리고 마지막으로 SecurityConfig bean 을 만들어서 이를 추가했습니다 각각 필요한 bean 을 만들고 필요한곳에 첨부했습니다 이렇게 해서 우리는 MAC 기반으로 JWT 토큰을 만들어서 form 로그인과 연계를 진행을 해보았습니다