🔓Simple Login App (hard-coded ver.)

dsunni·2020년 5월 17일
27

🙋🏻

  • React + Spring Boot(Maven) + JWT를 사용해 간단한 로그인을 구현했습니다.
  • JWT의 개념과 동작 과정을 우선적으로 공부하기 위해 DB 사용 없이 user의 id, pw가 하드코딩되어있습니다. (추후 DB 버전도 구현할 예정)
  • 아래 참고 부분의 강의들을 수강하며 진행했습니다.

Full Code

참고



JWT (Json Web Token)

📝 https://sanghaklee.tistory.com/47

1. JWT란?

  • JWT(Json Web Token)는 웹표준 (RFC 7519)으로 JSON 포맷을 이용해 정보를 가볍고 안전하게 전송하기 위한 Claim 기반의 Web Token이다.
  • 서버만 알고 있는 Secret Key로 디지털 서명화되어있기 때문에 신뢰할 수 있다
  • 보통 Authorization (로그인, SSO) 또는 안전한 정보 교환을 위해 사용된다.
  • JWT에서는 토큰 자체에 유저 정보를 담아서 HTTP 헤더에 전달하기에 유저 세션을 유지할 필요가 없다.

클레임(Claim)

  • 클레임(Claim)이란 사용자 정보나 데이터 속성 등을 의미한다.
  • 클레임 기반 토큰 안에는 사용자의 id, pw 등의 개인 정보가 들어있다.
    • Self-contained : 자체 포함, 토큰 자체가 정보
  • JWT는 가장 대표적인 클레임 기반 토큰이다.

JWT의 필요성

  • Session의 한계

    • Cookie는 정보를 클라이언트 측에 저장하고 Session은 정보를 서버측에 저장한다.
    • 따라서 유저의 수가 너무 많으면 서버 과부하
  • Scale Out의 한계

    • 서버 확장(scale out)시 세션 정보 동기화 문제
  • REST API는 Stateless를 지향

    • 사용자의 상태 정보를 저장하지 않는 형태 ex) 세션, 쿠키


2. JWT 형식

  • contain user authorization + any other information
  • header.payload.signature
    • Base64 Encoding
  • JWT에서 중요한 것은 payload의 data가 모든 이들에게 보인다는 점이다. 따라서 password와 같은 정보를 payload에 넣으면 안된다.
    • However,어떤 이가 악의적으로 payload를 변경하면 Secret Key로 인해 서버는 알 수 있다

Structure of JWT

  • Encoded
  • Decoded
    1. Header
      • alg : 해쉬 알고리즘
      • typ : 타입
    2. Payload : not mandatory / Additional information
      • sub : 어떤 것에 대해 말하는지
      • name : user의 이름 등..
      • iat : 토큰 생성 시간
      • exp : 만료 시간
    3. Signature
      • Base64 Encoded header + payload
      • 512 bit secret key (base64 Encoded)

3. 동작 과정

Spring Boot JWT Workflow

  1. 클라이언트 로그인 요청 POST(id, pw)
  2. 서버는 (id, pw)가 맞는지 확인 후 맞다면 JWT를 Secret Key로 생성 후 전달
  3. 클라이언트는 Token을 로컬 쿠키에 저장
  4. 클라이언트는 서버에 요청할 때 항상 헤더에 Token을 포함시킴
  5. 서버는 요청을 받을 때마다 Secret Kye를 사용해Token이 유효한지 검증
    • 서버만이 Secret Key를 가지고 있기 때문에 검증 가능
    • Token이 검증되면 따로 username, pw를 검사하지 않아도 user identification 가능
  6. 서버의 Response

4. Token 유효 검증

  1. 클라이언트의 요청 (Header : Token)
  2. Spring의 Interceptor에 의해 요청이 Intercept됨
  3. 클라이언트에게 제공되었던 Token과 클라이언트의 Header에 담긴 Token 일치 확인
  4. auth0 JWT를 이용해 issuer, expire 검증


준비 : React & Spring Boot 연동

  • 기존 React는 3000 port, Spring boot는 8090 port 사용
  • 연동을 위해서는 새로운 port가 필요 (4200 port)

1. React 포트 변경

package.json

"scripts": {
    "start": "set PORT=4200 && react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
},
  • start 부분에 set PORT=4200 && 추가
  • npm start


2. Axios 추가

React에서 axios 사용하기

  • npm add axios
  • package.json에 추가된 것을 확인 가능

용어 정리

  • Axios
    • 비동기 방식으로 HTTP 데이터 요청을 실행
    • 직접적으로 XMLHttpRequest를 다루지 않고 Ajax를 호출해서 사용
    • Axios의 리턴 값은 Promise 객체 형식
  • Ajax란 Asynchronous Javascript And Xml(비동기식 자바스크립트와 xml)의 약자로 XMLHttpRequest객체를 이용해서 데이터를 로드하는 기법
  • XMLHttpRequest란 웹 브라우저와 서버 사이의 메소드를 통해 데이터를 전송하는 객체 형식의 API
  • Promise란 자바스크립트 비동기 로직 처리에 사용되는 객체이다.
axios.get('URL').then().catch()
axios.post('URL').then().catch()
axos({
    key: value
    key2: value2
})

axios.post("/sample", {
    id: this.state.id, pw: this.state.pw
}).then(response => {console.log(response)});


Backend (Spring boot, JWT, Spring Security) 구조

  • Making use of hard coded user values for User Authentication
    • user_id, user_pw

1. JWT 생성

  • POST API with mapping /authenticate
  • id: user_id, pw: user_pw 입력시 사용자 확인 후 JWT 생성

Spring Boot JWT Generate Token

JwtRequestFilter

  • OncePerRequestFilter : Spring Security Configuration Class
    1. 모든 Request를 가로챈다
    2. Request의 헤더에 토큰이 있는지 확인한다
    3. 없다면 JwtAuthenticationController를 호출해 토큰을 생성한다

JwtAuthenticationController

  • Request Body에서 id, pw를 추출해 AuthenticationManager에게 전달
  • 올바른 id, pw가 인증이 되면
    • JwtTokenUtil의 generateToken(UserDetails)을 통해 토큰 생성

AuthenticationManager (Spring Security)

Spring Boot Security Authentication Manager

  • Spring security Configuration Class
  1. User Request Body에서 얻은 id, pw와
  2. JwtUserDetailsService를 호출해서 얻은 id, pw
    • 현재는 user_id로 하드코딩되어있다
  3. 두 id,pw를 비교해 사용자 일치하는지 검증
    • if 일치
      • return true
    • else if 불일치
      • throw invalid user exception

JwtUserDetailsService

  • user의 id, pw return

JwtTokenUtil

  • 토큰을 생성해 return

2. JWT 검증

  • GET API with mapping /hello
  • user가 유효한 JWT를 가지고 있을 때 접근이 허용됨

Spring Boot JWT Validate Token

JwtRequestFilter

  1. User Request's Header에서 Token이 있는지 확인
  2. 있다면 Token 추출
  3. JwtUserDetailsService에서 userName으로 user의 detail 얻어옴
  4. JwtTokenUtil에서 Token 유효성 검사
    • validateToken(jwtToken, userDetails)
  5. 유효한 Token이면 Spring Security에 유효한 User라 알려주고 hello() method에게 접근 가능하게 설정

JwtUserDetailsService

  • user의 detail을 return

JwtTokenUtil

  • Token 유효성 검사(Validate Token) 후 return True/False



Backend (Spring boot, JWT, Spring Security) 구현

🚨ERROR

java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter

  • JAVA 버전이 10 이상일 때 에러 생김

해결방법

<dependency>
    <groupId>org.glassfish.jaxb</groupId>
    <artifactId>jaxb-runtime</artifactId>
</dependency>
  • pom.xml에 위와 같은 dependency 추가


1. dependency 추가

pom.xml

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
  • spring-boot-starter-securityjjwt 추가


2. Secret Key 설정

  • Hashing algorithm과 함께 사용할 Secret Key를 설정
  • Secret Key는 Header, Payload와 결합되어 Hash 생성

application.properties

server.port=8090
jwt.secret=jwtsecretkey


3. JwtTokenUtil

  • JWT를 생성하고 검증하는 역할 수행
  • io.jsonwebtoken.Jwts 라이브러리 사용
@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;

    //retrieve username from jwt token
    public String getUsernameFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }

    //retrieve expiration date from jwt token
    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);
    }

    //for retrieveing any information from token we will need the secret key
    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());
    }

    //generate token for user
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return doGenerateToken(claims, userDetails.getUsername());
    }

    //while creating the token -
//1. Define  claims of the token, like Issuer, Expiration, Subject, and the ID
//2. Sign the JWT using the HS512 algorithm and secret key.
//3. According to JWS Compact Serialization(https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41#section-3.1)
//   compaction of the JWT to a URL-safe string
    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))
            .setExpiration(new Date(System.currentTimeMillis() + 5 * 1000))
            .signWith(SignatureAlgorithm.HS512, secret).compact();
    }

    //validate token
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }
}

doGenerateToken

  • Token 생성
    • claim : Token에 담을 정보
    • issuer : Token 발급자
    • subject : Token 제목
    • issuedate : Token 발급 시간
    • expiration : Token 만료 시간
      • milliseconds 기준!
      • JWT_TOKEN_VALIDITY = 5 60 60 => 5시간
  • signWith (알고리즘, 비밀키)


4. JwtUserDetailsService

  • DB에서 UserDetail를 얻어와 AuthenticationManager에게 제공하는 역할
  • 이번에는 DB 없이 하드코딩된 User List에서 get userDetail

@Service
public class JwtUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if ("user_id".equals(username)) {
            return new User("user_id", "$2a$10$m/enYHaLsCwH2dKMUAtQp.ksGOA6lq7Fd2pnMb4L.yT4GyeAPRPyS",
                new ArrayList<>());
        } else {
            throw new UsernameNotFoundException("User not found with username: " + username);
        }
    }
}
  • Spring Security 5.0에서는 Password를 BryptEncoder를 통해 Brypt화한다.
  • id : user_id, pw: user_pw로 고정해 사용자 확인
  • 사용자 확인 실패시 throw Exception


5. JwtAuthenticationController

  • 사용자가 입력한 id, pw를 body에 넣어서 POST API mapping /authenticate
  • 사용자의 id, pw를 검증
  • jwtTokenUtil을 호출해 Token을 생성하고 JwtResponse에 Token을 담아 return ResponseEntity

@RestController
@CrossOrigin
public class JwtAuthenticationController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Autowired
    private JwtUserDetailsService userDetailsService;

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

        final UserDetails userDetails = userDetailsService
            .loadUserByUsername(authenticationRequest.getUsername());

        final String token = jwtTokenUtil.generateToken(userDetails);

        return ResponseEntity.ok(new JwtResponse(token));
    }

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


6. JwtRequest

  • 사용자에게서 받은 id, pw를 저장
public class JwtRequest implements Serializable {

    private static final long serialVersionUID = 5926468583005150707L;

    private String username;
    private String password;

    //need default constructor for JSON Parsing
    public JwtRequest() {   }

    public JwtRequest(String username, String password) {
        this.setUsername(username);
        this.setPassword(password);
    }

    public String getUsername() {
        return this.username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return this.password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}


7. JwtResponse

  • 사용자에게 반환될 JWT를 담은 Response
public class JwtResponse implements Serializable {

    private static final long serialVersionUID = -8091879091924046844L;
    private final String jwttoken;

    public JwtResponse(String jwttoken) {
        this.jwttoken = jwttoken;
    }

    public String getToken() {
        return this.jwttoken;
    }
}


8. JwtRequestFilter

  • Client의 Request를 Intercept해서 Header의 Token가 유효한지 검증
  • if 유효한 Token
    • Spring Security의 Authentication을 Setting, to specify that the current user is authenticated

@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");
        }

        // Once we get the token validate it.
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

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

            // if token is valid configure Spring Security to manually set
            // authentication
            if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {

                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities());
                usernamePasswordAuthenticationToken
                    .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                // After setting the Authentication in the context, we specify
                // that the current user is authenticated. So it passes the
                // Spring Security Configurations successfully.
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }
        chain.doFilter(request, response);
    }
}
  • JWT Token은 Bearer (스페이스) 로 시작
  • jwtTokenUtil.validateToken을 통해 Token 유효성 검사
  • if true) UsernamePasswordAuthenticationToken을 설정해 유효한 사용자임을 Spring Security에게 알려준다


9. JwtAuthenticationEntryPoint

  • 접근 권한이 없는 사용자에게 401 Error를 보냄

@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");
    }
}


9. WebSecurityConfig

  • WebSecurity와 HttpSecurity를 커스터마이징
    • ex) CORS 등 해결

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Autowired
    private UserDetailsService jwtUserDetailsService;

    @Autowired
    private JwtRequestFilter jwtRequestFilter;

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        // configure AuthenticationManager so that it knows from where to load
        // user for matching credentials
        // Use BCryptPasswordEncoder
        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 {
        // For CORS error
        httpSecurity.cors().configurationSource(request -> new CorsConfiguration().applyPermitDefaultValues());
        // We don't need CSRF for this example
        httpSecurity.csrf().disable()
            // dont authenticate this particular request
            .authorizeRequests().antMatchers("/authenticate").permitAll().
            // all other requests need to be authenticated
                anyRequest().authenticated().and().
            
            // stateless session exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).and().sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

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


Postman으로 JWT Test

1. GET /hello


2. POST /authenticate

{
"username" : "user_id",
"password" : "user_pw"
}
  • Token이 return됨

3. /hello with Token



Frontend (React) 연동 부분 구현

1. AuthenticationService.js

import axios from 'axios'

class AuthenticationService {
    // send username, password to the SERVER
    executeJwtAuthenticationService(username, password) {
        return axios.post('http://localhost:8090/authenticate', {
            username,
            password
        })
    }

    executeHelloService() {
        console.log("===executeHelloService===")
        return axios.get('http://localhost:8090/hello');        
    }

    registerSuccessfulLoginForJwt(username, token) {
        console.log("===registerSuccessfulLoginForJwt===")
        localStorage.setItem('token', token);
        localStorage.setItem('authenticatedUser', username);
        // sessionStorage.setItem('authenticatedUser', username)
        //this.setupAxiosInterceptors(this.createJWTToken(token))
        this.setupAxiosInterceptors();
    }

    createJWTToken(token) {
        return 'Bearer ' + token
    }

    setupAxiosInterceptors() {
        axios.interceptors.request.use(
            config => {
                const token = localStorage.getItem('token');
                if (token) {
                    config.headers['Authorization'] = 'Bearer ' + token;
                }
                // config.headers['Content-Type'] = 'application/json';
                return config;
            },
            error => {
                Promise.reject(error)
            });
    }

    logout() {
        //sessionStorage.removeItem('authenticatedUser');
        localStorage.removeItem("authenticatedUser");
        localStorage.removeItem("token");
    }

    isUserLoggedIn() {
        const token = localStorage.getItem('token');
        console.log("===UserloggedInCheck===");
        console.log(token);

        if (token) {
            return true;
        }
        
        return false;
    }

    getLoggedInUserName() {
        //let user = sessionStorage.getItem('authenticatedUser')
        let user = localStorage.getItem('authenticatedUser');
        if(user===null) return '';
        return user;
    }
}

export default new AuthenticationService()

executeJwtAuthenticationService

  • body에 username과 password를 넣고 POST /authenticate

registerSuccessfulLoginForJwt

  • 로그인에 성공하면 username을 authenticatedUser로 localStorage에 저장
  • JWTToken을 생성해 setupAxiosInterceptors에 넣기

createJWTToken

  • 앞에 Bearer 를 추가해서 Token을 생성


2. Axios Interceptors

https://medium.com/swlh/handling-access-and-refresh-tokens-using-axios-interceptors-3970b601a5da

Axios는 자바스크립트에서 HTTP 통신을 위해 쓰이는 Promise 기반 HTTP Client이다.

Axios Interceptors는 모든 Request/Response가 목적지에 도달하기 전에 Request에 원하는 내용을 담아 보내거나 원하는 코드를 실행시킬 수 있다.


axios.interceptors.request.use(
    config => {
        const token = localStorage.getItem('token');
        if (token) {
            config.headers['Authorization'] = 'Bearer ' + token;
        }
        // config.headers['Content-Type'] = 'application/json';
        return config;
    },
    error => {
        Promise.reject(error)
    });
  • token이 있다면 header에 Bearer + token 담아서 보냄
  • 이후의 모든 Request의 Header에는 Token이 담겨져서 전달됨

🚨CORS Error

Interceptor을 사용해 header를 보내려 하는데 자꾸 CORS Error가 나온다.

만들어둔 WebSecurityConfig 파일에 아래의 라인을 추가하면 해결된다


WebSecurityConfig.java

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.cors().configurationSource(request -> new CorsConfiguration().applyPermitDefaultValues());
    ....
}


3. LoginComponent.jsx

loginClicked() {
    AuthenticationService
        .executeJwtAuthenticationService(this.state.username, this.state.password)
        .then((response) => {
        AuthenticationService.registerSuccessfulLoginForJwt(this.state.username,response.data.token)
        this.props.history.push(`/welcome/${this.state.username}`)
    }).catch( () =>{
        this.setState({showSuccessMessage:false})
        this.setState({hasLoginFailed:true})
    })
}
  • AuthenticationService.registerSuccessfulLoginForJwt(this.state.username,response.data.token)
    • username, response.data.token 을 사용해 사용자 확인
  • 로그인 성공하면 welcome page로 이동


4. 🚨withRouter

  • 로그인 유무(AuthenticationService.isUserLoggedIn())에 따라 헤더의 Navigation Bar가 다르게 보여야 하는데 Header가 매번 Re-Rendering되지 않는 현상이 발생
    • 로그인이 된 상태임에도 불구하고 Logout버튼 대신 Login 버튼이 보임
  • Route로 사용되지 않은 컴포넌트에서 조건부로 이동할때
    • 로그인 성공했을때 특정경로로가고 실패하면 가만히 있고싶다 할때 withRouter사용

withRouter로 해결

import { withRouter } from 'react-router'

const HeaderWithRouter = withRouter(HeaderComponent);
<HeaderWithRouter/>
  • HeaderComponet 대신 withRouter를 사용해 HeaderWithRouter를 생성
  • 라우터 컴포넌트가 아닌 곳에서 math, location, history 사용하는 속성이다.
profile
https://dsunni.tistory.com/ 이사갑니답

3개의 댓글

comment-user-thumbnail
2020년 8월 3일

좋은 정보 깔끔하게 정리해주셔서 감사합니다.

답글 달기
comment-user-thumbnail
2020년 10월 2일

감샴다

답글 달기
comment-user-thumbnail
2021년 7월 21일

authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));

이 부분이 의야해요
저코드가 해주는 역할이 뭔가요?
authenticate를 호출하면 db에서 user의 id pw를 조회해서 맞는지 틀린지 확인해주는건가요
근데 이건 loadbyusername에서 해주는거 아닌가..
뭐지 참 아리송

답글 달기