본문 바로가기

웹 개발/스프링

[스프링 부트 심화] 스프링 시큐리티 - 로그인, 회원가입 구현

0. 웹의 인증 및 인가의 개념

- 인증 (Authentication) : 사용자의 신원을 확인하는 행위

- 인가 (Authorization) : 사용자의 권한을 확인하는 행위

 

예를들어, 회사 출입을 위해 출입증을 확인하는 것은 인증입니다. 반면 회사 건물 내 접근 권한 관리를 인가라고 합니다. 

웹 에서 인증 및 인가는 다음과 같습니다.

- 인증 : 로그인 아이디, 패스워드

- 인가 : 사용 권한 관리 - 회원 별 랭킹

 

웹 서비스는 HTTP 통신을 합니다. HTTP는 상태를 저장하지 않는 "Stateless" 특성으로 인해 사용자를 구분할 수 없습니다. HTTP의 상태는 기억하지 않는 특성으로 인해 서버는 받게 된 요청이 같은 클라이언트에서 온 것인지 구분할 수 없습니다.

 

따라서 웹 서비스는 인증과 인가를 구현하기 위해 쿠키와 세션을 활용합니다. 쿠키와 세션은 모두 HTTP에 상태 정보를 유지 하기위해 사용됩니다. ("Stateful")

- 쿠키 : 클라이언트에 저장될 목적으로 생성한 작은 정보를 담은 파일입니다. 크롬 브라우저를 기준으로 개발자도구를 열어보면 Application - Storage - Cookies에 도메인 별로 저장되어 있는 것을 확인할 수 있습니다.

- 세션 : 세션은 쿠키와 달리 서버에서 저장합니다. 서버에서 '세션ID'는 클라이언트의 쿠키값을 저장하고 클라이언트는 쿠키에 세션ID를 저장해 식별에 사용합니다. 

 

 

 

요구사항 : 

 

 

1. 스프링 시큐리티를 이용한 인증 및 인가 관리 방법

스프링 시큐리티 프레임워크는 스프링 서버에 필요한 인증, 인가를 사용하기위한 많은 기능을 제공해 줍니다. 마치 스프링 프레임워크가 웹 서버 구현에 편의를 제공해줍니다. 

 

스프링 시큐리티 프레임 워크 추가하기 - build.gradle : 의존성 추가

// 스프링 시큐리티
implementation 'org.springframework.boot:spring-boot-starter-security'

스프링 시큐리티 활성화 : src>main>java>com.example>security>WebSecurityConfig : 

- @Configuration 어노테이션은 설정 파일을 의미합니다. 

- @EnableWebSecurity : Spring Security를 허용합니다.

- WebSecurityConfigurerAdapter를 상속받아 configure 함수를 override합니다. 인증, 인가 방식을 커스터마이징 할 수 있습니다. 

- 스프링 시큐리티는 기본 로그인 페이지를 제공합니다. Username은 user를 Password에는 spring 로그를 확인합니다. 

- 또한 로그아웃 기능도 지원합니다. 도메인에 /logout을 입력합니다. 

@Configuration
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.authorizeRequests()
	// 어떤 요청이든 '인증'
                .anyRequest().authenticated()
                .and()
	// 로그인 기능 허용
                .formLogin()
                .defaultSuccessUrl("/")
                .permitAll()
                .and()
	// 로그아웃 기능 허용
                .logout()
                .permitAll();
    }
}

타임리프 템플릿 엔진을 먼저 적용해 보겠습니다. build.gradle에 의존성 추가 : 

// Thymeleaf (뷰 템플릿 엔진)
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

resources > templates 폴더에 signup, login, style, index 리소스 추가

 

만약 다음과 같이 CSS가 적용되지 않았다면 개발자 도구의 네트워크를 확인해보자.

너무 열일해 CSS 파일마저 안보내준 스프링 시큐리티

URL 허용 정책을 변경해 줌으로서 로그인을 하기 전 이미지, CSS 등 보여줘야 할 리소스들의 정책을 허용합니다.

@Configuration
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.authorizeRequests()
            // 이미지 폴더, CSS 폴더 관련된 리소스는 login 없이 허용합니다.
                .antMatchers("/images/**").permitAll()
                .antMatchers("/css/**").permitAll()
            // 어떤 요청이든 '인증'
                .anyRequest().authenticated()
            .and()
            // 로그인 기능 허용
                .formLogin()
                .loginPage("/user/login")
                .defaultSuccessUrl("/")
                .failureUrl("/user/login?error")
                .permitAll()
            .and()
            // 로그아웃 기능 허용
                .logout()
                .permitAll();
    }
}

 

 

회원 테이블 설계 : 

 

 

- 패스워드의 암호화 적용하기 : 복호화가 불가능한 알고리즘을 적용해 DB에는 암호화된 비밀번호를 저장합니다. 유출이 발생할 경우 본래의 암호를 개발자나 해커가 알 수 없습니다. 

- BCrypt 해시함수 : 스프링 시큐리티에서 권고하는 패스워드 암호화 객체입니다. 

- 구현 : Bean 등록해 Service에서 DI를 받아 암호화를 진행합니다. 

    @Bean
    public BCryptPasswordEncoder encodePassword(){
        return new BCryptPasswordEncoder();
    }

 

 

 

- 스프링 시큐리티의 로그인, 로그 아웃 처리 과정 : 

- Client의 요청을 컨트롤러에 도달하기 전에 인증, 인가 절차를 Spring Security가 우선 검증합니다. 인증, 인가된 정보만 컨트롤러에 전달됩니다. 만약 인증/인가가 실패할 경우 컨트롤러에 도다하지 않습니다. 

- 스프링 시큐리티의 로그인 처리 과정을 자세히 살펴 보도록 하겠습니다.

클라이언트는 로그인을 시도하면 username과 password을 담아 로그인 시도 요청을 보냅니다. 스프링에서는 회원 아이디를 username으로 인식하고 있습니다. 클라이언트의 요청은 컨트롤러에 도달하기 이전에 Spring Security를 거칩니다. 가장 중추적으로 인증 과정을 관여 하는 AuthentiationManager 객체는 UserDetailsService 에게 username이 DB에 존재하는지 요청합니다. UserDetailsService는 확인할 username을 가지고 DB를 조회합니다. 만약 정보가 있다면 User 객체를 생성합니다. 생성된 User 객체는 다시 AuthenticationManager로 돌아오며 username과 password의 일치 여부를 확인합니다. AuthenticationManager에서 일치 여부가 확인될 경우 인증이 되며 세션이 생성됩니다. 만약 회원 정보가 DB에 없거나 불일치할 경우 Error를 발생시킵니다. 

- 로그아웃의 처리의 경우 세션을 스프링 시큐리티가 삭제합니다. 

 

- 구현 : UserDetailsService를 상속받은 UserDetailsServiceImpl 클래스에서 인증 관리자의 요구사항을 처리합니다. 인증관리자의 요구사항은 전달받은 username 문자열을 갖고 DB에 유저 정보를 가져오는 것입니다. 유저 정보는 UserDetails의 형태로 인증 관리자에게 전달됩니다.

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Autowired
    public UserDetailsServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("Can't find " + username));

        return new UserDetailsImpl(user);
    }
}

- UserDetailsService에서 조회한 유저 정보는 UserDetails에 담겨 인증관리자에게 전달됩니다. UserDetails를 구현한 UserDetailsImpl을 다음과 같이 작성하겠습니다. 

public class UserDetailsImpl implements UserDetails {

    private final User user;

    public UserDetailsImpl(User user) {
        this.user = user;
    }

    public User getUser() {
        return user;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.emptyList();
    }
}

- 인증 관리자는(AuthenticationManager) 스프링 시큐리티에서 관리됩니다. 

 

- 로그아웃 처리는 "GET /user/logout"이 아닌 "POST /user/logout"으로 처리해야 합니다. 기본적으로 CSRF protection이 활성화되어 있습니다. CSRF protection을 disable하면 "GET /user/logout"으로도 처리 가능하나 POST로 요청을 권장하고 있습니다. 

 

- 로그인에 성공한 회원은 컨트롤러에서 로그인된 회원 정보를 사용할 수 있습니다. 다음과 같은 양식으로 작성합니다. 

@Controller
public class TestController{
    @GetMapping("/")
    pubilc String test(@AuthenticationPrinciple UserDetailsImpl userDetails){
    }
}

 

 

- "POST /api/products"요청에 대한 HTTP 403 Forbidden : 해당 요청에대해 CSRF 처리를 해줍니다. 

하지만 당장 CSRF를 살펴보기 어렵기 때문에 http.csrf().disable(); 을 적용합니다. 또한 로그아웃은 POST로 요청해줍니다.

    <form id="my_form" method="post" action="/user/logout">
        <a id="logout-text" href="javascript:{}" onclick="document.getElementById('my_form').submit();">로그아웃</a>
    </form>

 

 

 

- 관심 상품 목록에 유저 ID (PK)를 맵핑하기 

 

- 관리자 상품 조회 : 

관리자는 일반 사용자와 달리 조회를 할 때 모든 사용자가 등록한 상품을 조회할 수 있습니다. 관리자를 위한 api를 추가합니다. - "GET /api/admin/products"

클라이언트에서는 일반 회원과 관리자를 구분하기가 어렵습니다. 서버에서 역할을 프론트에 내려 주는 것이 좋습니다. 구현 방버으로는 

1) 로그인 성공 시 Response에 회원 Role 추가하기

2) 쿠키에 저장하기

3) index.html에 admin 데이터 추가하기 (동적 웹 페이지를 사용) <div id="admin"></div> -> 프론트 개발자는 admin id가 있는 경우 관리자 API로 호출을 합니다. 

 

- 스프링 시큐리티의 권한(Authority)설정 : UserServiecImpl(회원 상세정보)를 통해 권한을 설정합니다.

스프링 권한 규칙 : 
- 권한은 1개 이상 설정 가능합니다.
- 권한 이름은 "ROLE_"로 시작해야 합니다. ex) "Admin"권한 -> "ROLE_ADMIN"

API 별 권한은 @Secured("권한 이름") 로 권한 설정이 가능합니다. 

@Secured 어노테이션은 시큐리티 설정파일에 @EnableGlobalMethodSecurity(securedEnabled = true)를 명시합니다.

또한 UserDetails의 getAuthorites를 overriding 합니다.

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 사용자의 권한 가져오기
        String authority = user.getRole().getAuthority();
        // 스프링 규칙 적용하기 - Collection 구조 "ROLE_" 등 파악
        SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
        Collection<GrantedAuthority> authorites = new ArrayList<>();
        authorites.add(simpleGrantedAuthority);
        return authorites;
    }

 

 

2. OAuth2를 이용한 소셜 로그인 : 번거로운 회원가입 부담을 줄입니다.

- 카카오 로그인 승인 받기 https://velog.io/@devmin/kakao-login-basic 

https://developers.kakao.com/docs/latest/ko/kakaologin/common

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

소셜 로그인(카카오) 기능 추가하기

카카오 로그인 기능을 적용해보려한다. 카카오 개발가이드를 토대로 기능을 작성해보자.

velog.io

앱 추가하기 -> 임의의 이름 설정

플랫폼 -> 웹, 도메인 - http://localhost:8080, Redirect URL 등록, 활성화

동의항목 설정 -> .닉네임, 이메일

 

- 인가코드 받기

    <button id="login-kakao-btn"
            onclick="location.href='https://kauth.kakao.com/oauth/authorize?client_id=내RESTSAPI키&redirect_uri=http://localhost:8080/user/kakao/callback&response_type=code'">
        카카오로 로그인하기
    </button>

Client ID에 내 REST API 키를 입력합니다. redirect_url에 callback 받을 uri를 명시합니다.

카카오는 callback_uri를 전달해줍니다.

 

- 인가코드로 토큰 요청

컨트롤러 : 

    @GetMapping("/user/kakao/callback")
    public String kakaoLogin(@RequestParam String code){
        userService.kakaoLogin(code);
        return "redirect:/";
    }

서비스 : 토큰 호출

// 1. "인가 코드"로 "액세스 토큰" 요청
// HTTP Header 생성
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

// HTTP Body 생성
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("client_id", "본인의 REST API키");
body.add("redirect_uri", "http://localhost:8080/user/kakao/callback");
body.add("code", code);

// HTTP 요청 보내기
HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest =
new HttpEntity<>(body, headers);
RestTemplate rt = new RestTemplate();
ResponseEntity<String> response = rt.exchange(
"https://kauth.kakao.com/oauth/token",
HttpMethod.POST,
kakaoTokenRequest,
String.class
);

// HTTP 응답 (JSON) -> 액세스 토큰 파싱
String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseBody);
String accessToken = jsonNode.get("access_token").asText();

- 토큰으로 API 호출 

// 2. 토큰으로 카카오 API 호출
// HTTP Header 생성
headers.add("Authorization", "Bearer " + accessToken);
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

// HTTP 요청 보내기
HttpEntity<MultiValueMap<String, String>> kakaoUserInfoRequest = new HttpEntity<>(headers);
response = rt.exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.POST,
kakaoUserInfoRequest,
String.class
);

responseBody = response.getBody();
jsonNode = objectMapper.readTree(responseBody);
Long id = jsonNode.get("id").asLong();
String nickname = jsonNode.get("properties")
.get("nickname").asText();
String email = jsonNode.get("kakao_account")
.get("email").asText();

System.out.println("카카오 사용자 정보: " + id + ", " + nickname + ", " + email);

- 카카오 사용자 정보로 로그인 처리 해주기 

로그인한 사용자 정보인 UserDetails는 SecurityContext에 저장됩니다. User 객체를 만들어 준 후 usernamePasswordAuthenticationToken을 생성해 강제 로그인을 처리하겠습니다.

 private void forceLoginWithKakao(User kakaoUser) {
        UserDetails userDetails = new UserDetailsImpl(kakaoUser);
        Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

 

 

3. JWT를 이용한 로그인

JWT 토큰의 필요성 : 서버는 대용량 트래픽 처리를 위해 두 대 이상으로 운용하고 있습니다. 로드 밸런서는 여러 요청을 여러 서버 컴퓨터에 분산해 처리해줍니다. 그러면 서버의 갯수만큼 세션의 개수가 늘어나 로그인 정보가 흩어져 있게 됩니다.  

만약 로그인 정보를 가지고 있지 않은 다른 서버에 API 요청이 되면 로그인을 다시 해야 합니다. 이러한 문제를 해결하기위해 다음과 같은 방법으로 해결 합니다.

1) Sticky Session : 

클라이언트 마다 요청을 할 서버를 고정합니다. 즉, Client1은 오직 Server1에만 요청을 보내며 Client2는 오직 Server2에만 요청을 보냅니다. 하지만 로드 밸런서가 되입된 이유는 균등하게 요청을 분산하기 위함인데 하나의 서버로 고정이 된다면 로드 배런서의 역할을 제대로 수행되지 못하게 됩니다. 또한 클라이언트와 서버 간의 매핑 정보가 필요해집니다.

2) 세션 저장소 :

로그인 정보를 하나의 스토리지에서 관리합니다. Redis 등과 같은 db에 세션 정보를 하나로 묶어 관리합니다. 모든 Client의 로그인 정보를 소유하게 됩니다. 

3) JWT 사용

로그인 정보를 서버에 저장하지 않고, 클라이언트에 JWT로 암호화 하여 저장합니다. 이 JWT로 인증/ 인가를 가능케 합니다. 이 때, 모든 서버는 하나의 동일한 Secret Key를 소유하며, 이를 통해 암호화와 위조 검증을 수행합니다.

- JWT 장단점 : 

JWT 장점 : 

 - 동시 접속사가 많을 경우 서버 부하를 낮춘다.

- 서버, 클라이언트가 다른 도메인을 사용할 때 cors 문제를 해결하기에 좋다.

 

JWT 단점 : 

- 구현이 복잡하다.

- JWT에 담은 내용이 커질수록 네트워크 비용이 증가한다. 항상 클라이언트에서 서버로 요청할 때 JWT를 함께 전송해야 한다.

- 이미 발급된 JWT를 만료시킬 방법이 없다.

- Secret Key가 유출될 시 JWT 내용을 조작해 사용할 수 있다. 

 

 

- JWT 사용흐름

1) 클라이언트가 username, password로 로그인을 성공할 시

- 로그인 정보가 JWT로 암호화됩니다.

- 서버는 JWT를 클라이언트로 응답에 전달해줍니다.

- 클라이언트는 JWT를 쿠키, Local Storage, 메모리 등에 저장합니다.

 

2)  JWT 토큰을 인증 방법

- 클라이언트는 JWT를 모든 API 요청 마다 Header에 포함시킵니다. 

Content-Type : application/json
Authorization : Bearer <JWT>

- 서버는 다음과 같은 절차를 수행합니다.

  • 클라이언트가 전달한 JWT를 위조검증 합니다.(secret key)
  • JWT 의 유효시간을 검증합니다. Refresh 토큰으로 유효시간을 갱신할 수 있습니다 .
  • 검증 성공 시에는 로그인을 성공한 사용자이며 UserDetailsImpl을 만드러서 로그인 정보를 만듭니다. ex) GET /api/products는 JWT를 보낸 사용자의 관심 상품의 목록을 조회합니다.  

- JWT의 구조

  • JWT는 누구나 평문으로 복호화가 가능합니다. 하지만 Secret key가 없을 경우 JWT의 수정이 불가능 합니다. (read-only)
  • JWT는 Header.Payload(내용).signature(서명) 으로 구성되어 있습니다
  • signature의 secret key는 payload의 위 변조 여부를 확인할 수 있습니다. 

 

 

- JWT의 동작 

  • JwtAuthFilter,;  FormLoginFilter는 컨트롤러의 앞 단에서 JWT의 유효성을 검증합니다. 
  • JwtAuthFilter : 모든 API에 대하여 JWT를 확인 합니다. 단, 로그인 전 허용이 되어야 하는 API는 예외 처리가 필요합니다.  -> FilteSkipMatcher
  • POST/user/login은 body에 username과 password를 전송하고 있습니다. 이 요청은 로그인 성공 전의 요청임으로 예외 처리가 되어야 합니다. DB에서 확인외 될 경우 로그인 성공 시에 JWT가 생성이 됩니다. 이 때, JWT를 포함해 응답을 하며 클라이언트는 JWT를 저장합니다. 또한 모든 API 요청은 Header에 JWT를 포함시켜 요청을 보냅니다.
  • 로그인 성공 이후 모든 요청에는 JWT가 포함되며 JwtAuthFilter에서 JWT의 유효성 검사를 진행합니다. 만약 유효한 경우에만 API 호출을 허용하며 로그인 정보를 생성합니다. (UserDetailsImpl) 이렇게 통과된 경우에만 로그인된 사용자의 관심 상품을 조회합니다. 

JWT 로그인 인증 처리 Filters 

- FormLoginFilter : 

  • Filters는 Client API요청이 Controller로 전달되기 전 인증 처리를 위해 사용됩니다.
  • FormLoginFilter : 클라이언트의 폼 로그인 요청 시 (POST "/user/login") username과 password를 인증합니다. (GET "/user/login"요청은 GET "/user/loginView"로 변경해야 합니다. )
  • 클라이언트로 전달받은 username과 password를 전달받아 인증을 수행하며 인증 성공 시 FormLoginSuccessHandler를 통해 JWT 토큰을 생성합니다. 이후 클라이언트는 모든 API 응답 헤더에 JWT 토큰을 포함 시킨 채로 인증합니다.

- JWTAuthFilter : API 요청 헤더에 전달되는 JWT 토큰의 유효성을 인증합니다. 

 

인증처리 Provider : 

  • Filter가 인증에 필요한 정보를 적합한 클래스의 형태로 만들어 Spring Security에 인증을 요청합니다.  
  • 스프링 시큐리티는 Filter가 요청한 인증 처리를 할 수 있는 Provider를 찾고 실제 인증 처리는 Provider에서 진행됩니다.
    • 인증 처리 여부는 supports 함수를 통해 "인증 정보의 클래스 타입"을 보고 판단합니다. 
    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
  • FormLoginAuthProvider : 클라이언트에서 전달한 아이디, 패스워드가 DB와 일치하는지 인증합니다. 
  • FormLoginSuccessHandler : 인증이 성공한 후 토큰을 생성합니다. 

JwtAuthProvider : 모든 요청에 대하여 JwtAuthFilter에서 토큰을 추출한 후, authenticate 합니다. 그러면 JwtAuthProvider가 호출되며 실제적으로 jwt가 유효한지 검증합니다. 복호화를 해 username을 가져옵니다. 동시에 JwtDecoder에서 유효기간, 유저 정보 등을 유효성 검사를 합니다. username을 가지고 User 객체를 갖져오면 UserDetails에 해당 유저 객체를 전달합니다.