HyunJun 기술 블로그

스프링 시큐리티 JWT 토큰 인증 구현 본문

Spring Framework

스프링 시큐리티 JWT 토큰 인증 구현

공부 좋아 2023. 5. 17. 17:40
728x90
반응형

1. 스프링 시큐리티란?


Spring 기반의 애플리케이션의 보안(인증, 인가)을 담당하는 스프링 하위 프레임워크이다.

 

  • 보안 관점에서의 인증과, 권한에 따른 인가 설정을 Filter로 처리한다.
  • Filter는 Dispatcher Servlet으로 가기 전에 적용된다.
  • 이 글에서는 기본적인 시큐리티, JWT Access Token 활용법만 기술합니다.

2. 구현하기

  • 타임 리프 + 스프링 시큐리티(JWT)로 회원가입 페이지 -> 로그인 페이지 -> 메인 페이지 -> 권한 필요 페이지 4가지를 표현하도록 구현해 볼게요.
  • 권한은 멤버, 프라이빗 멤버, VIP 멤버, 관리자로 4가지 권한을 나누어 보았습니다.
  • 프런트엔드 서버가 따로 있는 게 아닌 스프링 하나로 서버사이드 렌더링으로 HTML을 표현하고 있기 때문에, 스프링 서버 url로 접근할 때 토큰의 유무를 확인하기 위해서 클라이언트단에 쿠키로 저장하여 구현해 보겠습니다.
  • 클라이언트는 매 요청마다 자동으로 쿠키를 서버로 전달합니다.

패키지 구조

 

build.gradle

dependencies {
    /*Web*/
    implementation 'org.springframework.boot:spring-boot-starter-web'

    /*Security*/
    implementation 'org.springframework.boot:spring-boot-starter-security'

    /*Thymeleaf*/
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
    
    /*lombok*/
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    
    /*test*/
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

    /*JPA, Mysql*/
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.mysql:mysql-connector-j'

    /*JWT 의존성*/
    compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
}

 

 

 

application.yml

spring:
  profiles:
    include: datasource, jwt
  jpa:
    hibernate:
      ddl-auto: update
  • datasource, jwt는 공유되지 않게 따로 yml로 관리합니다.
  • ddl-auto: update는 실제 사용 프로젝트가 아니므로 update로 간단하게 테이블을 생성합니다.
  • datasource 정보는 노출 시 database의 data 유출 위험, 과부하 사용량 위험 등이 있으며,
    jwt secret-key는 유출이 되면 토큰 생성 및 변조가 가능하므로 유출이 되면 안 됩니다!

 

 

application-datasource.yml

spring:
  datasource:
    url: jdbc:mysql://my---server.ap-northeast-2.rds.amazonaws.com:3306/spring-jwt
    username: 유저네임
    password: 비밀번호

 

application-jwt.yml

jwt:
  secret:
    key: qwrmlasfmlqw2491u4291urjeiqowfjkwklfnlksdnfi13fnou3nounf13

 

 

 

.gitignore

application-datasource.yml
application-jwt.yml

깃허브에 올라가지 않게 .gitignore에 등록을 합니다.

 

 

 

 

SpringSecurityConfig

package com.example.springsecurityjwt.config;


import com.example.springsecurityjwt.jwt.JwtAuthFilter;
import com.example.springsecurityjwt.jwt.JwtUtil;
import com.example.springsecurityjwt.security.UserRoleEnum;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SpringSecurityConfig {

    private final JwtUtil jwtUtil;

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

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        // resources 자원 접근 허용
        return (web) -> web.ignoring()
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        /* csrf 설정 해제. */
        http.csrf().disable();

        /*JWT 사용 위해 기존의 세션 방식 인증 해제*/
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        /* URL Mapping */
        http.authorizeRequests()
                .antMatchers(HttpMethod.GET, "/signup").permitAll()/*회원가입 페이지는 무조건 허용*/
                .antMatchers(HttpMethod.GET, "/login").permitAll()/*로그인 페이지 허용.*/
                .antMatchers(HttpMethod.POST, "/api/signup").permitAll()/*회원가입 요청은 무조건 허용*/
                .antMatchers(HttpMethod.POST, "/api/login").permitAll()/*로그인 요청 허용*/
                .antMatchers("/cookie/test").permitAll()
                .antMatchers("/vip").hasRole(UserRoleEnum.VIP_MEMBER.toString())/*vip 멤버 전용 페이지*/
                .antMatchers("/admin").hasRole(UserRoleEnum.ADMIN.toString())/*관리자 전용 페이지*/
                .anyRequest().authenticated()/*나머지 요청은 전부 인증되어야 함*/
                /*JwtAuthFilter에 jwtUtil을 전달하여 UsernamePasswordAuthenticationFilter전에 필터로 등록한다.*/
                .and().addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);



        /*로그인 페이지를 /login으로 설정한다.
        1. 인증이 되지 않은 사용자가 permitAll()페이지가 아닌 페이지에 접근할 때 /login으로 강제 이동 시킨다.
        2. 이때의 인증은 위에 필터에 등록해 놓은 JWT 토큰의 유무(유효성 검증) 기준이다.*/
        http.formLogin().loginPage("/login");

        /*인가 (권한 인증) 실패 시 아래의 핸들러 작동 ex) 멤버인데 -> VIP 멤버의 페이지를 접근하는 경우*/
        http.exceptionHandling().accessDeniedPage("/forbidden");


        return http.build();
    }


}

 

 

 

MemberController

package com.example.springsecurityjwt.controller;


import com.example.springsecurityjwt.dto.LoginRequestDto;
import com.example.springsecurityjwt.dto.SignUpRequestDto;
import com.example.springsecurityjwt.jwt.JwtUtil;
import com.example.springsecurityjwt.security.UserDetailsImpl;
import com.example.springsecurityjwt.service.MemberService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;


@Slf4j
@Controller
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    /*index 뷰*/
    @GetMapping("/index")
    public String index() {
        return "index";
    }

    /*로그인 뷰*/
    @GetMapping("/login")
    public String login(@AuthenticationPrincipal UserDetailsImpl userDetails) {

        /*이미 로그인된 사용자일 경우 인덱스 페이지로 강제이동.*/
        if (userDetails != null) {
            log.info(userDetails.getMember().getMemberName() + "님이 로그인 페이지로 이동을 시도함. -> index 페이지로 강제 이동 함.");
            return "redirect:/index";
        }

        return "login";
    }

    /*회원가입 뷰*/
    @GetMapping("/signup")
    public String signUp() {
        return "signUp";
    }

    /*vip 전용 페이지 뷰*/
    @GetMapping("/vip")
    public String vip(HttpServletRequest request) {
        return "role";
    }

    /*관리자 페이지 뷰*/
    @GetMapping("/admin")
    public String system(HttpServletRequest request) {
        return "role";
    }

    /*회원가입 API*/
    @PostMapping("/api/signup")
    public String signUp(SignUpRequestDto requestDto) {
        memberService.signUp(requestDto);
        return "redirect:/login";
    }
    
    /*로그인 API*/
    @PostMapping("/api/login")
    public String login(LoginRequestDto requestDto, HttpServletResponse response) {
        memberService.login(requestDto, response);
        return "redirect:/index";
    }

    /*서버 로그 쿠키 테스트 확인용*/
    @GetMapping("/cookie/test")
    public String test(@CookieValue(value = "Authorization", defaultValue = "", required = false) String test) {
        log.info(test);
        return "index";
    }

    /*403, forbidden -> 인가 실패 시*/
    @GetMapping("/forbidden")
    public String forbidden() {
        log.warn("비정상적인 접근: 403 forbidden");
        /*따로 횟수를 기록한다던지 로직*/
        return "redirect:/index";
    }

    /*로그아웃 API*/
    @GetMapping("/api/logout")
    public String logout(@CookieValue(value = "Authorization", defaultValue = "", required = false) Cookie jwtCookie,
                         HttpServletResponse response) {
        /*jwt 쿠키를 가지고와서 제거한다.*/
        jwtCookie.setValue(null);
        jwtCookie.setMaxAge(0);
        jwtCookie.setPath("/");
        response.addCookie(jwtCookie);

        return "redirect:/login";
    }
}

 

 

 

 

 

LoginRequestDto

package com.example.springsecurityjwt.dto;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class LoginRequestDto {
    private String memberName;
    private String password;
}

 

 

 

 

SignUpRequestDto

package com.example.springsecurityjwt.dto;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class SignUpRequestDto {
    private String memberName;
    private String password;
    private String role;
}

 

 

 

Member

package com.example.springsecurityjwt.entity;

import com.example.springsecurityjwt.security.UserRoleEnum;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@NoArgsConstructor
@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String memberName;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private UserRoleEnum role;

    public Member(String memberName, String password, UserRoleEnum role) {
        this.memberName = memberName;
        this.password = password;
        this.role = role;
    }

}

 

 

 

 

MemberRepository

package com.example.springsecurityjwt.repositroy;

import com.example.springsecurityjwt.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByMemberName(String memberName);
}

 

 

 

 

UserDetailsImpl

package com.example.springsecurityjwt.security;

import com.example.springsecurityjwt.entity.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;


@Slf4j
public class UserDetailsImpl implements UserDetails {

    private final Member member;
    private final String password;
    private final String username;

    public UserDetailsImpl(Member member, String password, String username) {
        this.member = member;
        this.password = password;
        this.username = username;
    }

    public Member getMember(){
        return this.member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        UserRoleEnum role = member.getRole();
        String authority = role.getAuthority();

        SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(simpleGrantedAuthority);

        return authorities;
    }

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

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

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }/*false: 사용자 계정의 유효 기간 만료*/

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }/*false: 계정 잠금 상태*/

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }/*false: 비밀번호 만료*/

    @Override
    public boolean isEnabled() {
        return true;
    } /*false: 유효하지 않은 사용자*/
}

 

 

 

 

UserDetailsServiceImpl

package com.example.springsecurityjwt.security;

import com.example.springsecurityjwt.entity.Member;
import com.example.springsecurityjwt.repositroy.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;

@Service
@RequiredArgsConstructor
@Slf4j
public class UserDetailsServiceImpl implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String memberName) throws UsernameNotFoundException {
        Member member = memberRepository.findByMemberName(memberName)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));

        return new UserDetailsImpl(member, member.getPassword(), member.getMemberName());
    }

}

 

 

UserRoleEnum

package com.example.springsecurityjwt.security;

public enum UserRoleEnum {
    
    MEMBER(Authority.MEMBER),
    PRIVATE_MEMBER(Authority.PRIVATE_MEMBER),
    VIP_MEMBER(Authority.VIP_MEMBER),
    ADMIN(Authority.ADMIN);
    
    private final String authority;

    UserRoleEnum(String authority) {
        this.authority = authority;
    }

    public String getAuthority() {
        return this.authority;
    }

    public static class Authority {
        public static final String MEMBER = "ROLE_MEMBER";
        public static final String PRIVATE_MEMBER = "ROLE_PRIVATE_MEMBER";
        public static final String VIP_MEMBER = "ROLE_VIP_MEMBER";
        public static final String ADMIN = "ROLE_ADMIN";
    }

    public static UserRoleEnum fromString(String role) {
        switch (role) {
            case "ROLE_MEMBER":
                return MEMBER;
            case "ROLE_PRIVATE_MEMBER":
                return PRIVATE_MEMBER;
            case "ROLE_VIP_MEMBER":
                return VIP_MEMBER;
            case "ROLE_ADMIN":
                return ADMIN;
        }

        return null;
    }
}

 

 

 

MemberService

package com.example.springsecurityjwt.service;


import com.example.springsecurityjwt.security.UserRoleEnum;
import com.example.springsecurityjwt.dto.LoginRequestDto;
import com.example.springsecurityjwt.dto.SignUpRequestDto;
import com.example.springsecurityjwt.entity.Member;
import com.example.springsecurityjwt.jwt.JwtUtil;
import com.example.springsecurityjwt.repositroy.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import java.util.Optional;

@Service
@Slf4j
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;
    private final JwtUtil jwtUtil;
    private final PasswordEncoder passwordEncoder;

    /*회원 가입*/
    @Transactional
    public void signUp(SignUpRequestDto requestDto) {

        /*아이디*/
        String memberName = requestDto.getMemberName();
        /*패스워드*/
        String password = passwordEncoder.encode(requestDto.getPassword());
        /*유저 권한*/
        UserRoleEnum role = UserRoleEnum.valueOf(requestDto.getRole());

        Member member = new Member(memberName, password, role);
        memberRepository.save(member);

    }

    /*로그인*/
    @Transactional
    public void login(LoginRequestDto requestDto, HttpServletResponse response) {

        Optional<Member> optionalMember = memberRepository.findByMemberName(requestDto.getMemberName());

        if (optionalMember.isEmpty()) {
            log.warn("회원이 존재하지 않음");
            throw new IllegalArgumentException("회원이 존재하지 않음");
        }

        Member member = optionalMember.get();

        /*비밀번호 다름.*/
        if (!passwordEncoder.matches(requestDto.getPassword(), member.getPassword())) {
            log.warn("비밀번호가 일치하지 않습니다.");
            throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
        }


        /*토큰을 쿠키로 발급 및 응답에 추가*/
        Cookie cookie = new Cookie(JwtUtil.AUTHORIZATION_HEADER,
                jwtUtil.createToken(member.getMemberName(), member.getRole()));
        cookie.setMaxAge(7 * 24 * 60 * 60); // 7일 동안 유효
        cookie.setPath("/");
        cookie.setDomain("localhost");
        cookie.setSecure(false);

        response.addCookie(cookie);

    }

}

 

 

 

JwtAuthFilter

package com.example.springsecurityjwt.jwt;

import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
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;

@Slf4j
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

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

        String token = jwtUtil.resolveToken(request);

        if (token != null) {
            if (!jwtUtil.validateToken(token)) {
                log.warn("JWT Token 인증 실패");
                throw new IllegalArgumentException("JWT Token 인증 실패");
            }
            Claims info = jwtUtil.getUserInfoFromToken(token);
            setAuthentication(info.getSubject());
        }
        /*다음 필터 진행*/
        filterChain.doFilter(request, response);
    }

    public void setAuthentication(String username) {
        /*jwt 인증 성공 시*/
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        Authentication authentication = jwtUtil.createAuthentication(username);
        context.setAuthentication(authentication);

        SecurityContextHolder.setContext(context);
    }


}

 

 

 

 

JwtUtil

package com.example.springsecurityjwt.jwt;

import com.example.springsecurityjwt.security.UserRoleEnum;
import com.example.springsecurityjwt.security.UserDetailsServiceImpl;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.annotation.PostConstruct;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.security.Key;
import java.util.Base64;
import java.util.Date;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtUtil {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String AUTHORIZATION_KEY = "auth";
    private static final String BEARER_PREFIX = "Bearer=";
    private static final long TOKEN_TIME = 60 * 60 * 1000L;

    private final UserDetailsServiceImpl userDetailsService;

    @Value("${jwt.secret.key}")
    private String secretKey;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    // header 토큰을 가져오기 -> 헤더검사 없으면 쿠키검사
    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

        /*헤더에 값이 없다면 토큰 확인*/
        if (bearerToken == null) {
            Cookie[] cookies = request.getCookies(); // 모든 쿠키 가져오기
            if (cookies != null) {
                for (Cookie c : cookies) {
                    String name = c.getName(); // 쿠키 이름 가져오기

                    String value = c.getValue(); // 쿠키 값 가져오기
                    if (name.equals(AUTHORIZATION_HEADER)) {
                        bearerToken = value;
                    }
                }
            }
        }

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }

    // 토큰 생성
    public String createToken(String username, UserRoleEnum role) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(username)
                        .claim(AUTHORIZATION_KEY, role)
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))
                        .setIssuedAt(date)
                        .signWith(key, signatureAlgorithm)
                        .compact();
    }

    // 토큰 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT token, 만료된 JWT token 입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }

    // 토큰에서 사용자 정보 가져오기
    public Claims getUserInfoFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }

    // 인증 객체 생성
    public Authentication createAuthentication(String username) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }

}

 

 

 

로직은 아래와 같습니다.

  • 회원가입: 암호화 비밀번호, 권한 DB 저장
  • 로그인: 아이디 비교, 패스워드 인코더로 입력한 평문 비밀번호와 저장되어 있는 암호화된 비밀번호 비교, 일치한다면 로그인 성공 -> jwt 토큰 쿠키에 저장하여 클라이언트에게 전달
  • 쿠키로 저장되기 때문에 매 요청마다 자동으로 쿠키(JWT 토큰)를 서버로 보냄
  • 요청마다 서버에 도착한 JWT 토큰의 유효성을 검증하고 로그인 성공 처리
  • 인증이 필요한 페이지에 인증이 안된 경우 /login, 인가가 안 된 경우  /forbidden으로 이동
  • 로그아웃 시 쿠키에 저장된 토큰 무효화

 

 

3. HTML 구현

타임리프에서 제공하는 시큐리티 기능과, 인증 로직이 스프링 서버에서 돌아가기 때문에 프런트엔드 단에서는 쉽습니다.

 

signUp.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>

<script>
    function passwordValidation() {

        let memberName = document.getElementById("memberName").value;
        let password = document.getElementById("password").value;

        let check = /(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9])(?=.{8,})/;

        if (check.test(password)) {
            return true;
        } else {
            alert("비밀번호는 최소 하나 이상의 소문자, 하나의 대문자, 하나의 숫자, 하나의 특수문자, 최소 8 자리 입니다.");
            return false;
        }

    }
</script>

<body>
<!--스프링 서버에서는 id가 아닌 name으로 설정해주어야 인식함-->
<div>
    <div>SignUp</div>

    <form method="POST" action="/api/signup" onsubmit="return passwordValidation()">
        <div>MemberName</div>
        <input type="text" id="memberName" name="memberName" placeholder="아이디">

        <div>Password</div>
        <input type="password" id="password" name="password">

        <input type="radio" name="role" value="MEMBER" checked>멤버
        <input type="radio" name="role" value="PRIVATE_MEMBER">프라이빗 멤버
        <input type="radio" name="role" value="VIP_MEMBER">VIP
        <input type="radio" name="role" value="ADMIN">관리자

        <input type="submit" name="submit" value="회원 가입">
    </form>

</div>

</body>
</html>

 

 

 

 

login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>

<script>
    function loginValidation() {
        /*비정상적 접근 거부*/

        let memberName = document.getElementById("memberName").value;
        let password = document.getElementById("password").value;

        //공백만 입력된 경우
        var blank_pattern = /^\s+|\s+$/g;
        if(
            memberName.replace(blank_pattern, '' ) == "" ||
            password.replace(blank_pattern, '' ) == ""
        ){
            alert('공백만 입력되었습니다.');
            return false; /*폼 전송하지 않음*/
        }

        //문자열에 공백이 있는 경우
        var blank_pattern = /[\s]/g;
        if(
            blank_pattern.test(memberName) == true ||
            blank_pattern.test(password) == true
        ){
            alert('공백이 입력되었습니다.');
            return false;
        }

        //특수문자가 있는 경우
        var special_pattern = /[`~!@#$%^&*|\\\'\";:\/?]/gi;
        if(special_pattern.test(memberName) == true){
            alert('특수문자가 입력되었습니다.');
            return false;
        }

        //공백 혹은 특수문자가 있는 경우
        if(memberName.search(/\W|\s/g) > -1){
            alert( '특수문자 또는 공백이 입력되었습니다.');
            return false;
        }

        return true;/*폼 전송*/
    }

</script>

<body>
<div>
    <div>Login</div>
    <form method="POST" action="/api/login" onsubmit="return loginValidation()">
        <div>MemberName</div>
        <input type="text" id="memberName" name="memberName" placeholder="아이디">

        <button type="button" onclick="location.href='/signup'">회원가입</button>

        <div>Password</div>
        <input type="password" id="password" name="password">

        <input type="submit" name="submit" value="로그인">
    </form>
</div>
</body>
</html>

 

 

 

 

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>로그인 성공</h1>

<button onclick="location.href='/vip'">VIP 페이지로 이동</button>
<button onclick="location.href='/admin'">Admin 페이지로 이동</button>
<button onclick="location.href='/cookie/test'">cookie test</button>
<button onclick="location.href='/api/logout'">Logout</button>
</body>
</html>

 

 

 

 

 

role.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <!--ADMIN 역할을 갖는다면 이 글이 보임-->
    <div sec:authorize="hasRole('ROLE_ADMIN')">
        ADMIN <span style="color:green;" sec:authentication="principal.username"></span>님 안녕하세요.
    </div>

    <!--VIP_MEMBER 역할을 갖는다면 이 글이 보임-->
    <div sec:authorize="hasRole('ROLE_VIP_MEMBER')">
        VIP_MEMBER <span style="color:green;" sec:authentication="name"></span>님 안녕하세요.
    </div>


    <div>
        소유한 권한들 : <span sec:authentication="principal.authorities"></span>
    </div>

    <!--어떤 역할이건 상관없이 인증이 되었다면 이 글이 보임-->
    <h3 sec:authorize="isAuthenticated()">인증된 사람만 이 글을 볼 수 있습니다.</h3>

    <div>
        name : <span sec:authentication="name"></span>
    </div>


    <div>
        principal.username : <span sec:authentication="principal.username"></span>
    </div>

    <div>
        principal.password : <span sec:authentication="principal.password"></span>
    </div>

    <div>
        principal.enabled : <span sec:authentication="principal.enabled"></span>
    </div>

    <div>
        principal.accountNonLocked : <span sec:authentication="principal.accountNonLocked"></span>
    </div>

    <div>
        principal.accountNonExpired : <span sec:authentication="principal.accountNonExpired"></span>
    </div>

    <div>
        principal.credentialsNonExpired : <span sec:authentication="principal.credentialsNonExpired"></span>
    </div>
</body>
</html>

 

 

 

4. 결과 확인

 

Github

728x90
반응형
Comments