본문 바로가기
🍃 𝗦𝗽𝗿𝗶𝗻𝗴 𝗕𝗼𝗼𝘁

Spring Boot + JWT + Security + Security 권한 설정해서 '회원가입/로그인 구현'

by 비타민찌 2022. 3. 18.
728x90

Spring Boot + JWT + Security + Security 권한 설정해서 회원가입/로그인 구현.

 

구현하고자 하는 전체 로직은 다음과 같다.

1. ID/PW 로그인 시도 ->

          <- 2. ID/PW 검증 후 Access Token 발급

3. API 요청 시,
Access Token 헤더에 담아서 요청 ->

          <- 4. API 응답 또는
            Access Token 만료 응답 줌
 

 

1. JWT

JWT에 관련된 글은 따로 작성했기 때문에 링크로 대체.

1) JWT(Json Web Token) :: JWT 형식, 동작 과정
2) 스프링 부트 JWT 인증 과정 (더 좋은 코드)

 

2. Spring Security의 구조

Spring Security는 사용자 정보 (ID/PW) 검증 및 유저 정보 관리 등을 쉽게 사용할 수 있도록 제공한다.

* 스프링 시큐리티는 원래 세션 기반 인증을 사용하기 때문에 JWT와 별개로 생각해야 한다.

여기서는 일단 필요한 시큐리티 정보만 세팅하고 어떤 식으로 동작하는지만 파악한다.

 

 

필요한 Settings

- build.gradle Spring Security 추가

implementation 'org.springframework.boot:spring-boot-starter-security'
 

 

- Jwt Dependency 추가

implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
 

 

3. Member 도메인 설계

시큐리티 자체적으로 UserDetails의 구현체인 User를 사용하기 때문에 헷갈리지 않도록 Account나 Member로 이름 짓는 게 좋다고 한다.

[ Member 도메인 ]

- Member Dao

- MemberService

- MemberController

 

3-1) Member DAO

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Repository;
import javax.sql.DataSource;
import java.util.ArrayList;
import java.util.Arrays;

@Repository
public class MemberDao {
    private JdbcTemplate jdbcTemplate;

    @Autowired
    public void setDataSource(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }


    public PostMemberRes createMember(PostMemberReq postMemberReq) {

        String createMemberQuery = "insert into Member (email, password, nickname ) VALUES (?, ?, ?)";
        Object[] createMemberParams = new Object[]{postMemberReq.getEmail(), postMemberReq.getPassword(), postMemberReq.getNickname(),
        };
        this.jdbcTemplate.update(createMemberQuery, createMemberParams);

        String getLastInsertIdxQuery = "select last_insert_id()";
        int lastInsertIdx = this.jdbcTemplate.queryForObject(getLastInsertIdxQuery, int.class);

        String createAuthorityQuery = "insert into authority values(?, ?)";
        Object[] createAuthorityParams = new Object[]{lastInsertIdx, 0};
        this.jdbcTemplate.update(createAuthorityQuery, createAuthorityParams);
        return new PostMemberRes(lastInsertIdx, 1);
    }


    public PostMemberRes createSeller(PostMemberReq postMemberReq) {

        String createMemberQuery = "insert into Member (email, password, nickname ) VALUES (?, ?, ?)";
        Object[] createMemberParams = new Object[]{postMemberReq.getEmail(), postMemberReq.getPassword(), postMemberReq.getNickname(),
        };
        this.jdbcTemplate.update(createMemberQuery, createMemberParams);

        String getLastInsertIdxQuery = "select last_insert_id()";
        int lastInsertIdx = this.jdbcTemplate.queryForObject(getLastInsertIdxQuery, int.class);

        String createAuthorityQuery = "insert into authority values(?, ?)";
        Object[] createAuthorityParams = new Object[]{lastInsertIdx, 0};
        this.jdbcTemplate.update(createAuthorityQuery, createAuthorityParams);

        createAuthorityQuery = "insert into authority values(?, ?)";
        createAuthorityParams = new Object[]{lastInsertIdx, 1};
        this.jdbcTemplate.update(createAuthorityQuery, createAuthorityParams);
        return new PostMemberRes(lastInsertIdx, 1);
    }

    public UserLoginRes findByEmail(String email) {
        String getEmailQuery = "SELECT * FROM member LEFT OUTER JOIN authority on member.idx=authority.member_idx WHERE email=?";

        return this.jdbcTemplate.queryForObject(getEmailQuery
                , (rs, rowNum) -> new UserLoginRes(
                        rs.getObject("idx", int.class),
                        rs.getString("email"),
                        rs.getString("password"),
                        Arrays.asList(new SimpleGrantedAuthority(Authority.values()[rs.getObject("role", int.class)].toString()))
                ), email);
    }

    public Boolean getUserEmail(String email) {
        String findEmailQuery = "SELECT * FROM member WHERE email=?";
        try {
            UserLoginRes userLoginRes = this.jdbcTemplate.queryForObject(findEmailQuery
                    , (rs, rowNum) -> new UserLoginRes(
                            rs.getObject("idx", int.class),
                            rs.getString("email"),
                            rs.getString("password"),
                            new ArrayList<>()
                    ), email);
            if (userLoginRes.getEmail() != null) {
                return true;
            } else {
                return false;
            }
        } catch (EmptyResultDataAccessException e) {
            return false;

        }
    }
}
 

쇼핑몰 사이트라 가정하고, member 유저와 seller 유저가 있다.

member는 특별한 인증 없이 가입이 가능하지만

seller는 판매자라 쇼핑몰 측에 판매자 신청을 하고, 심사에 통과되면 쇼핑몰 측에서 seller 가입 url을 주는 시나리오다.

 

3-2) Authority

public enum Authority {
    ROLE_MEMBER,  ROLE_SELLER,  ROLE_ADMIN
}
 

권한은 Enum 클래스로 구현.

 

3-3) Member DB

1:N 관계

member 테이블과 authority 테이블은 위와 같이 구성했다.

한 member는 여러개의 authority를 부여받을 수 있다. 이렇게 구현한 이유는 페이지 접근 권한 때문이다.

member는 상품 조회 페이지에만 접근할 수 있고,

seller는 상품 등록 페이지와 상품 조회 페이지 모두 접근 할 수 있어야 한다.

role 0이 접근할 수 있는 페이지 : 상품 조회 페이지
role 1이 접근할 수 있는 페이지 : 상품 등록 페이지

 

그래서 member role은 0, seller role은 0,1 두 개를 부여한다.

🤔 member 테이블과 authority 테이블을 나눈 이유

멤버 테이블 즉 사용자 테이블을 만들 때,

이름 나이 성별 이메일 전화번호 등은 보통 한 사람 당 하나씩만 있을 테니 하나의 테이블로 만는다.

그런데 만약 취미라던가 다른 여러가지의 값을 가질 수 있는 정보를 같은 테이블에 만들면 문제가 생긴다.

예를 들어..

이름
성별
기술
토찌
여자
html
토찌
여자
css

토찌의 기술이 뭐가 있는지 기술에 대한 정보만 추가하고 싶은데,

계속 여자라는 정보도 같이 넣어야 하는 거다.

중복된 데이터가 쓸데없이 추가되는 거고,

만약 이름이 바뀌거나 성별이 바뀌면 그에따라 또 수정해야하는 데이터가 많아진다.

귀찮기도 할 뿐더러 연산도 늘어나니 비효율적이다.

그럼 테이블을 따로 만든다면?

이름
성별
토찌
여자
토찌
여자
이름
기술
토찌
HTML
토찌
CSS

이름이나 성별이 바뀌어도 그 부분 한번만 고쳐주면 된다!

이런 이유 때문에 여러 개의 값을 가질 수 있는 데이터들은 따로 테이블을 만드는게 좋다.

여기서는 그것이 authority다.

 

3-3) MemberService

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class MemberService {

    @Autowired
    PasswordEncoder passwordEncoder;

    @Autowired
    MemberDao memberDao;

    public PostMemberRes createMember(PostMemberReq postMemberReq) {
        postMemberReq.setPassword(passwordEncoder.encode(postMemberReq.getPassword()));
        return memberDao.createMember(postMemberReq);
    }

    @Transactional
    public PostMemberRes createSeller(PostMemberReq postMemberReq) {
        postMemberReq.setPassword(passwordEncoder.encode(postMemberReq.getPassword()));
        return memberDao.createSeller(postMemberReq);
    }

    public Boolean getUserEmail(String email) {
        return memberDao.getUserEmail(email);
    }
}
 

 

createSeller 메서드는 seller role에 0과 1을 부여해야 하는데,
혹시 0만 부여한 상태에서 에러가 났을 경우를 대비하여 @Transactional 어노테이션(스프링에서 지원하는 선언적 트랜잭션)을 달았다.


3-4) MemberController

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/member")
public class MemberController {
    @Autowired
    MemberService memberService;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;


    @ResponseBody
    @PostMapping("/signup")
    public BaseResponse<PostMemberRes> createMember(@RequestBody PostMemberReq postMemberReq) {

        try {
            System.out.println("========================== Req: " + postMemberReq);
            PostMemberRes postMemberRes = memberService.createMember(postMemberReq);
            return new BaseResponse<>(postMemberRes);

        } catch (Exception exception) {
            System.out.println(exception);
            return new BaseResponse<>(BaseResponseStatus.FAIL);
        }
    }

    @ResponseBody
    @PostMapping("/sellersignup")
    public BaseResponse<PostMemberRes> createSeller(@RequestBody PostMemberReq postMemberReq) {
        try {
            System.out.println("========================== Req: " + postMemberReq);

            PostMemberRes postMemberRes = memberService.createSeller(postMemberReq);
            return new BaseResponse<>(postMemberRes);

        } catch (Exception exception) {
            System.out.println(exception);
            return new BaseResponse<>(BaseResponseStatus.FAIL);
        }
    }

    @ResponseBody
    @GetMapping("{email}")
    public Boolean getUserEmail(@PathVariable("email") String email) {
        Boolean result = memberService.getUserEmail(email);
        return result;
    }

    @RequestMapping(value = "/authenticate", method = RequestMethod.POST)
    public ResponseEntity<?> createAuthenticationToken(@RequestBody JwtRequest authenticationRequest) throws Exception {
        System.out.println(authenticationRequest.getUsername());
        System.out.println(authenticationRequest.getPassword());

        Authentication authentication = authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword());
        UserLoginRes userLoginRes = (UserLoginRes) authentication.getPrincipal();
        final String token = jwtTokenUtil.generateToken(userLoginRes);
        return ResponseEntity.ok(new JwtResponse(token));
    }

    private Authentication authenticate(String username, String password) throws Exception {
        try {
            return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
        } catch (DisabledException e) {
            throw new Exception("USER_DISABLED", e);
        } catch (BadCredentialsException e) {
            throw new Exception("INVALID_CREDENTIALS", e);
        }
    }
}
 

 

 

4. JWT와 Security 설정

JWT 관련

JwtTokenUtil : 유저 정보로 JWT 토큰을 만들거나 토큰을 바탕으로 유저 정보를 가져옴.

JwtFilter: Spring Request 앞단에 붙일 Custom Filter

 

Spring Security 관련

JwtAuthenticationEntryPoint: 인증 정보 없을 때 401 에러

WebSecurityConfig: 스프링 시큐리티에 필요한 설정, JWT Filter를 추가

SecurityUtil: SecurityContext에서 전역으로 유저 정보를 제공하는 유틸 클래스

 

 

 

✅ JwtTokenUtil.java

JWT token구현에 핵심이 되는 클래스. JWT 토큰에 관련된 암호화, 복호화, 검증 로직은 다 이곳에서 이루어진다.

@Component
public class JwtTokenUtil implements Serializable {
    private static final long serialVersionUID = -2550185165626007488L;
    public static final long JWT_TOKEN_VALIDITY = 5 * 60 * 60;
    @Value("${jwt.secret}")
    private String secret;

    //jwt 토큰에서 사용자 이름 검색.
    public String getUsernameFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }

    //jwt 토큰에서 만료 날짜 검색.
    public Date getExpirationDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getExpiration);
    }

    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }

    // JWT에서 회원 정보 추출.
    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
    }

    //check if the token has expired
    private Boolean isTokenExpired(String token) {
        final Date expiration = getExpirationDateFromToken(token);
        return expiration.before(new Date());
    }

    //Jwt 생성.
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return doGenerateToken(claims, userDetails.getUsername());
    }

    //Jwt 발급.
    private String doGenerateToken(Map<String, Object> claims, String subject) {
        return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis()))
            .setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY * 1000))
            .signWith(SignatureAlgorithm.HS512, secret).compact();
    }

    // 토큰유효성 + 만료일자 확인.
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
 

해당 클래스에서는 token 발급, token에서 username을 추출, token의 유효성 검사를 한다.

 

 

✅ jwtRequestFilter.java

import io.jsonwebtoken.ExpiredJwtException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JwtRequestFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUserDetailsService jwtUserDetailsService;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        final String requestTokenHeader = request.getHeader("Authorization");

        String username = null;
        String jwtToken = null;

        // JWT Token is in the form "Bearer token". Remove Bearer word and get only the Token
        if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
            jwtToken = requestTokenHeader.substring(7);
            try {
                username = jwtTokenUtil.getUsernameFromToken(jwtToken);
            } catch (IllegalArgumentException e) {
                System.out.println("Unable to get JWT Token");
            } catch (ExpiredJwtException e) {
                System.out.println("JWT Token has expired");
            }
        } else {
            logger.warn("JWT Token does not begin with Bearer String");
        }

        // 토큰을 받으면 유효성을 검사.
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

            UserDetails userDetails = this.jwtUserDetailsService.loadUserByUsername(username);

            // 토큰이 유효한 경우.. 수동으로 인증을 설정하도록 Spring Security를 구성
            if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {
                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                usernamePasswordAuthenticationToken
                        .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                // 컨텍스트에서 인증을 설정한 후, 현재 사용자가 인증되었음을 지정. Spring Security Configurations 성공적으로 통과.
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }
        chain.doFilter(request, response);
    }

}
 

jwtRequestFilter 역시 중요한 부분 중 하나인데 OncePerRequestFilter를 상속받은 클래스로써 요청당 한 번의 filter를 수행하도록 doFilterInternal() 메서드를 구현해주면 된다.

 

doFilterInternal

실제 필터링 로직을 수행하는 곳.

Request Header에서 Authorization값을 꺼내어 토큰을 검사하고 여러 가지 검사 후 유저 정보를 꺼내서 SecurityContext에 저장한다.

가입/로그인/재발급을 제외한 모든 Request 요청은 이 필터를 거치기 때문에 토큰 정보가 없거나 유효하지 않으면 정상적으로 수행되지 않는다.

 

 

✅ WebSecurityConfig

Spring Security를 사용하기 위해 Spring Security Filter Chain을 사용한다.

WebSecurityConfigurerAdapter를 상속받은 클래스에 @EnableWebSecurity 어노테이션을 추가한다.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Autowired
    private JwtAccessDeniedHandler jwtAccessDeniedHandler;

    @Autowired
    private UserDetailsService jwtUserDetailsService;

    @Autowired
    private JwtRequestFilter jwtRequestFilter;

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        // configure AuthenticationManager so that it knows from where to load user for matching credentials
        auth.userDetailsService(jwtUserDetailsService).passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.cors().configurationSource(request -> new CorsConfiguration().applyPermitDefaultValues());
        // CSRF 설정 Disable
        httpSecurity.csrf().disable()

                // 특정 API는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정.
                .authorizeRequests().antMatchers("/member/*").permitAll()
                .antMatchers("/**").permitAll().

                // all other requests need to be authenticated
                        anyRequest().authenticated().and().

                // exception handling 할 때 우리가 만든 클래스를 추가.
                        exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)

                // 시큐리티는 기본적으로 세션을 사용하지만, 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정.
                .and().sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        // Add a filter to validate the tokens with every request
        httpSecurity.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
 

- httpSecurity.cors().configurationSource : 스프링 시큐리티 CORS 허용 정책.

- csrf() : html tag를 통한 공격 ( api 서버 이용 시 disable() )

 

- exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint) :

exceptionHandling을 위해서 실제 구현한 jwtAuthenticationEntryPoint을 넣어준다. jwtAuthenticationEntryPoint 아래에서 구현한다.

 

- sessionManagement().sessionCreationPolicy(SessionCreationPolicy.

STATELESS) : 시큐리티에서 세션을 생성하지도 않고, 기존 것을 사용하지 않기 위한 전략

→ JWT 토큰 방식 사용하기 위함.

 

addFilterBefore() : 인증을 처리하는 기본 필터 UsernamePasswordAuthenticationFilter

대신 JwtAuthenticationFilter라는 별도의 인증 로직을 가진 커스텀 필터 추가.

→ jwtTokenProvider를 생성자 파라미터로 넣은 이유는 JwtAuthenticationFilter에 종속성을 추가하기 위함.

 

/member/* 에 해당하는 url들은 권한을 가진 사용자만 접근 가능.

/product/** 에 해당하는 url들은 MEMBER 권한을 가진 사용자만 접근 가능.

- 그 외 나머지 url들은 권한 상관없이 접근 가능 (permitAll)

 

 

✅ JwtAuthenticationEntryPoint.java

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.Serializable;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {

    private static final long serialVersionUID = -7858869558953243875L;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {

        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}
 

JwtAuthenticationEntryPoint는 Jwt 검증 시 발생하는 Exception을 처리해주기 위해, AuthenticationEntryPoint를 구현하여 인증에 실패한 사용자의 response에 HttpServletResponse.SC_UNAUTHORIZED를 담아주도록 구현한다.

 

✅ JwtAccessDeniedHandler

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // 필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}
 

 

 

✅ JwtUserDetailsService.java

@Service
public class JwtUserDetailsService implements UserDetailsService {
    @Autowired MemberDao memberDao;

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

        UserLoginRes userLoginRes = memberDao.findByEmail(username);
        System.out.println("userLoginRes: "+userLoginRes);

        if (userLoginRes != null) {
            return new UserLoginRes(userLoginRes.getIdx(), userLoginRes.getEmail(), userLoginRes.getPassword(),
                    new ArrayList<>());
        } else {
            throw new UsernameNotFoundException("User not found with username: " + username);
        }
    }
}
 

JwtUserDetailsService는 들어온 email으로 Member를 찾아서 결과적으로 User 객체를 반환해주는 역할 + 컨트롤러에서 넘어온 email과 password 값이 db에 저장된 비밀번호와 일치하는지 검사한다.

 

 

5. 사용자 인증 과정

지금까지 스프링 시큐리티와 JWT를 사용하기 위한 설정들을 다 정리했다.

지금부터는 실제로 사용자 로그인 요청이 들어왔을 때 인증 처리 후에 JWT 토큰을 발급하는 과정에 대해 알아본다.

 

 

✅ JwtUserDetailsService.java

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;

@Service
public class JwtUserDetailsService implements UserDetailsService {
    @Autowired MemberDao memberDao;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserLoginRes userLoginRes = memberDao.findByEmail(username);
        System.out.println("userLoginRes: "+userLoginRes);

        if (userLoginRes != null) {
            return new UserLoginRes(userLoginRes.getIdx(), userLoginRes.getEmail(), userLoginRes.getPassword(),
                    new ArrayList<>());
        } else {
            throw new UsernameNotFoundException("User not found with username: " + username);
        }
    }
}
 

UserDetailsService 인터페이스를 구현한 클래스.

loadUserByUsername 메서드를 오버라이드 하는데 여기서 넘겨받은 UserDetails와 Authentication의 패스워드를 비교하고 검증하는 로직을 처리한다.

 

구현하는 동안 대체 어디서 패스워드를 비교하는지 궁금했는데,

loadUserByUsername 메서드를 어디서 호출하는지 내부를 타고 들어가 보면...

JwtUserDetailsService.java

loadUserByUsername를 여러 곳에서 호출하고 있는데, 이 중에서 DaoAuthenticationProvider 내부를 확인해보자.

DaoAuthenticationProvider

username을 받아서 넘겨주는 retrieveUser 메서드 내부에서 호출한다.

이 retrieveUser는 어디서 호출하냐면..

AbstractUserDetailsAuthenticationProvider
DaoAuthenticationProvider 의 부모 클래스인 AbstractUserDetailsAuthenticationProvider 에서 호출하고 있었다. DaoAuthenticationProvider 를 다시 확인해보면 오버라이드 해서 구현되어 있다.
DaoAuthenticationProvider
실제로 비밀번호 검증이 이루어지는 부분은 이 곳이었다.

 

Request로 받아서 만든 authentication와 DB에서 꺼낸 값인 userDetails 의 비밀번호를 비교한다.

DB 에 있는 값은 암호화된 값이지만 passwordEncoder 가 알아서 비교해주는 것 같다.

 

 

6. API 호출 테스트

이제 서버를 띄우고 API 호출을 해본다. API 요청은 Advanced REST client를 사용했다.

 

6-1) 가입

{
  "email":"minji03@minji.com",
  "password":"minji03",
  "nickname":"minji03"
}
 

권한 테스트를 위해 위 방식으로 minji01, minji02, minji03을 가입시켰다.

디비 확인

 

minji02에게는 role을 0으로 일반 member 권한을 부여하고,

minji03에게는 seller라는 권한을 부여했다.

 

6-2) 로그인

[일반 멤버 로그인]

http://localhost:8080/member/authenticate
 
minji02로 로그인.
토큰 발급.

이 토큰으로 /product/lists"에 접근한다.

WebSecurityConfig 파일에서

antMatchers("/product/lists").authenticated() 설정했으므로

접근이 허용되어야 한다.

접근 성공.

이번엔 .antMatchers("/product/search").hasRole("SELLER")로

seller 권한이 부여된 사용자만 접근 하능 한 url로 접근 시도.

구현한대로 403 forbidden 에러,

서버가 허용하지 않는 웹 페이지나 미디어를 사용자가 요청할 때 웹 서버가 반환하는 HTTP 상태 코드를 반환했다.

 

이번엔 seller 권한을 부여한 minji03으로 로그인해보자.

http://localhost:8080/member/authenticate
 
마찬가지로 토큰 발급.

토큰을 가지고 아까는 403 에러가 떴던 /product/search로 접근해보자.

200OK, 구현한대로 접근이 허용된다.

 

 

 


어려워서 시행착오가 길었지만 이렇게 앞 뒤가 잘 되는 걸 보면 뿌듯하다..ㅠㅠ

아직도 손 봐야 할 부분들이 꽤 있지만..

우선 지금은 쿼리문으로 직접 디비에서 member 권한을 update 해줬는데, 추후 admin만 접근 가능한 페이지에서 등업(?) 기능도 구현할 예정이다. 그 외에도 빼먹은 기능들이 좀 있고 좋지 못한 코드도 많다ㅠㅠ 발전시키는 대로 해당 포스팅도 수정해야지!

 

그리고.. 하면서 의문이었던 건 JPA를 왜 쓰는가? 였다. 저번 프로젝트에서는 비슷한 내용을 JPA로 구현했었는데, 디비가 알아서 생기는 것 외엔 뭐가 딱히 좋은 점이 느껴지지 않았다. 디비도 사실 직접 쿼리문을 써주는 게 나로서는 덜 복잡한 것 같았고.ㅠㅠ 

참고 : https://velog.io/@tjdals080/spring-boot-security-jwt3 , https://bcp0109.tistory.com/301 , https://www.youtube.com/watch?v=vBrQ3yzerMg&t=40s

728x90

댓글