새소식

TIL

[TIL] 230524 <Spring> 인증과 인가, 사용자 관리하기

  • -

 

[JWT]

  • 모든 서버에서 동일한 Secret Key 소유함
  • Secret Key 통한 암호화 / 위조 검증 (복호화 시)
  • JWT 는 누구나 평문으로 복호화 가능
  • 하지만 Secret Key 가 없으면 JWT 수정 불가능
  • → 결국 JWT 는 Read only 데이터
  • 1. Header  2. Payload  3. Signature
    ㄴ Payload에 실제 유저의 정보가 들어있고, HEADER와 VERIFY SIGNATURE부분은 암호화 관련된 정보 양식
  • 장점
    • 동시 접속자가 많을 때 서버 측 부하 낮춤
    • Client, Sever 가 다른 도메인을 사용할 때
      • 예) 카카오 OAuth2 로그인 시 JWT Token 사용
  • 단점
    • 구현의 복잡도 증가
    • JWT 에 담는 내용이 커질 수록 네트워크 비용 증가 (클라이언트 → 서버)
    • 기 생성된 JWT 를 일부만 만료시킬 방법이 없음
    • Secret key 유출 시 JWT 조작 가능

 


spring-auth 프로젝트의 build.gradle에 JWT dependency 추가

// JWT
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

application.properties

jwt.secret.key=7Iqk7YyM66W07YOA7L2U65Sp7YG065+9U3ByaW5n6rCV7J2Y7Yqc7YSw7LWc7JuQ67mI7J6F64uI64ukLg==

 

JwtUtil 만들기

Util 클래스란 특정 매개 변수(파라미터)에 대한 작업을 수행하는 메서드들이 존재하는 클래스
ㄴ 다른 객체에 의존하지 않고 하나의 모듈로서 동작하는 클래스라고 생각하시면 좋습니다.

<JWT 관련 기능을 가지는 JwtUtil 클래스>
1. JWT 생성
2. 생성된 JWT를 Cookie에 저장
3. Cookie에 들어있던 JWT 토큰을 Substring
4. JWT 검증
5. JWT에서 사용자 정보 가져오기

 

0. 토큰 생성에 필요한 데이터

JwtUtil

package com.sparta.springauth.jwt;

import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Base64;

@Component
public class JwtUtil {

	//0. JWT 데이터

	// Header KEY 값
	public static final String AUTHORIZATION_HEADER = "Authorization";
	// 사용자 권한 값의 KEY
	public static final String AUTHORIZATION_KEY = "auth";
	// Token 식별자
	public static final String BEARER_PREFIX = "Bearer ";
	// 토큰 만료시간
	private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분

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

	// 로그 설정
	public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");

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

}
  • Base64로 Encode된 Secret Key를 properties에 작성해두고 @Value를 통해 가져옴
  • JWT를 생성할 때 가져온 Secret Key로 암호화함 (application.properties에 넣었던 값)
    • 이때 Encode된 Secret Key를 Decode 해서 사용
    • Key는 Decode된 Secret Key를 담는 객체
    • @PostConstruct는 딱 한 번만 받아오면 되는 값을 사용 할 때마다 요청을 새로 호출하는 실수를 방지하기 위해 사용
      ㄴ JwtUtil 클래스의 생성자 호출 이후에 실행되어 Key 필드에 값을 주입해줌
  • 암호화 알고리즘은 HS256 알고리즘을 사용
  • Bearer란 JWT 혹은 OAuth에 대한 토큰을 사용한다는 표시
  • 로깅이란 애플리케이션이 동작하는 동안 프로젝트의 상태나 동작 정보를 시간순으로 기록하는 것
    ㄴ Logback 로깅 프레임워크를 사용해서 로깅을 진행 예정

 

entity>UserRoleEnum (사용자의 권한의 종류)

public enum UserRoleEnum {
    USER(Authority.USER),  // 사용자 권한
    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 USER = "ROLE_USER";
        public static final String ADMIN = "ROLE_ADMIN";
    }
}

 

 

1. JWT 생성

JwtUtil

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

		return BEARER_PREFIX +
			Jwts.builder()
				.setSubject(username) // 사용자 식별자값(ID)
				.claim(AUTHORIZATION_KEY, role) // 사용자 권한
				.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
				.setIssuedAt(date) // 발급일
				.signWith(key, signatureAlgorithm) // 암호화 알고리즘
				.compact();
	}
  • JWT의 subject에 사용자의 식별값 즉, ID를 넣음
  • JWT에 사용자의 권한 정보를 넣음. key-value 형식으로 key 값을 통해 확인가능
  • 토큰 만료시간을 넣음 (ms 기준)
  • issuedAt에 발급일을 넣음
  • signWith에 secretKey 값을 담고있는 key와 암호화 알고리즘을 값을 넣어줌
    • key와 암호화 알고리즘을 사용하여 JWT를 암호화

 

 

2. 생성된 JWT를 Cookie에 저장

JwtUtil

	//2. 생성한 JWT를 Cookie에 저장
	public void addJwtToCookie(String token, HttpServletResponse res) {
		try {
			token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행

			Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
			cookie.setPath("/");

			// Response 객체에 Cookie 추가
			res.addCookie(cookie);
		} catch (UnsupportedEncodingException e) {
			logger.error(e.getMessage());
		}
	}

 

 

3. 받아온 Cookie에 들어있던 Cookie의 Value인 JWT 토큰을 Substring

JwtUtil

	//3. JWT 토큰 substring
	public String substringToken(String tokenValue) {
		if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
			return tokenValue.substring(7);
		}
		logger.error("Not Found Token");
		throw new NullPointerException("Not Found Token");
	}
  • StringUtils.hasText를 사용하여 공백, null을 확인하고 startsWith을 사용하여 토큰의 시작값이 Bearer이 맞는지 확인
  • 맞다면 순수 JWT를 반환하기 위해 substring을 사용하여 Bearer을 잘라냄

 

 

4.JWT 검증

JwtUtil

	//4. JWT 토큰 검증
	public boolean validateToken(String token) {
		try {
			Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
			return true;
		} catch (SecurityException | MalformedJwtException | SignatureException e) {
			logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
		} catch (ExpiredJwtException e) {
			logger.error("Expired JWT token, 만료된 JWT token 입니다.");
		} catch (UnsupportedJwtException e) {
			logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
		} catch (IllegalArgumentException e) {
			logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
		}
		return false;
	}
  • Jwts.parserBuilder() 를 사용하여 JWT를 파싱
  • JWT가 위변조되지 않았는지 secretKey(key)값을 넣어 확인

 

 

5.JWT에서 사용자 정보 가져오기

JwtUtil

	//5. JWT에서 사용자 정보 가져오기
	public Claims getUserInfoFromToken(String token) {
		return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
	}
  • JWT의 구조 중 Payload 부분에는 토큰에 담긴 정보가 들어있음
  • 여기에 담긴 정보의 한 ‘조각’ 을 클레임(claim) 이라고 부르고, 이는 key-value 의 한 쌍으로 이뤄져있음. 토큰에는 여러개의 클레임 들을 넣을 수 있음
  • Jwts.parserBuilder() 와 secretKey를 사용하여 JWT의 Claims를 가져와 담겨 있는 사용자의 정보를 사용

 

 

JwtUtil 테스트

auth>AuthController

@RestController
@RequestMapping("/api")
public class AuthController {

	public static final String AUTHORIZATION_HEADER = "Authorization";

	public final JwtUtil jwtUtil;

	public AuthController(JwtUtil jwtUtil) {
		this.jwtUtil = jwtUtil;
	}

	// 쿠키 생성
	@GetMapping("/create-cookie")
	public String createCookie(HttpServletResponse res) {
		addCookie("Robbie Auth", res);

		return "createCookie";
	}

	// 쿠키 읽기
	@GetMapping("/get-cookie")
	public String getCookie(@CookieValue(AUTHORIZATION_HEADER) String value) {
		System.out.println("value = " + value);

		return "getCookie : " + value;
	}

	// HttpSession 생성
	@GetMapping("/create-session")
	public String createSession(HttpServletRequest req) {
		// 세션이 존재할 경우 세션 반환, 없을 경우 새로운 세션을 생성한 후 반환
		HttpSession session = req.getSession(true);

		// 세션에 저장될 정보 Name - Value 를 추가합니다.
		session.setAttribute(AUTHORIZATION_HEADER, "Robbie Auth");

		return "createSession";
	}

	// HttpSession 읽기
	@GetMapping("/get-session")
	public String getSession(HttpServletRequest req) {
		// 세션이 존재할 경우 세션 반환, 없을 경우 null 반환
		HttpSession session = req.getSession(false);

		String value = (String) session.getAttribute(AUTHORIZATION_HEADER); // 가져온 세션에 저장된 Value 를 Name 을 사용하여 가져옵니다.
		System.out.println("value = " + value);

		return "getSession : " + value;
	}





	@GetMapping("/create-jwt")
	public String createJwt(HttpServletResponse res) {
		// Jwt 생성
		String token = jwtUtil.createToken("Robbie", UserRoleEnum.USER);

		// Jwt 쿠키 저장
		jwtUtil.addJwtToCookie(token, res);

		return "createJwt : " + token;
	}

	@GetMapping("/get-jwt")
	public String getJwt(@CookieValue(JwtUtil.AUTHORIZATION_HEADER) String tokenValue) {
		// JWT 토큰 substring
		String token = jwtUtil.substringToken(tokenValue);

		// 토큰 검증
		if(!jwtUtil.validateToken(token)){
			throw new IllegalArgumentException("Token Error");
		}

		// 토큰에서 사용자 정보 가져오기
		Claims info = jwtUtil.getUserInfoFromToken(token);
		// 사용자 username
		String username = info.getSubject();
		System.out.println("username = " + username);
		// 사용자 권한
		String authority = (String) info.get(JwtUtil.AUTHORIZATION_KEY);
		System.out.println("authority = " + authority);

		return "getJwt : " + username + ", " + authority;
	}






	// 쿠키 생성
	public static void addCookie(String cookieValue, HttpServletResponse res) {
		try {
			cookieValue = URLEncoder.encode(cookieValue, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행

			Cookie cookie = new Cookie(AUTHORIZATION_HEADER, cookieValue); // Name-Value
			cookie.setPath("/");
			cookie.setMaxAge(30 * 60);

			// Response 객체에 Cookie 추가
			res.addCookie(cookie);
		} catch (UnsupportedEncodingException e) {
			throw new RuntimeException(e.getMessage());
		}
	}
}

 

 


 

spring-auth 프로젝트의 build.gradle dependency에 JPA, MySQL 추가

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

application.properties에 아래 내용 추가

spring.datasource.url=jdbc:mysql://localhost:3306/auth
spring.datasource.username=root
spring.datasource.password={비밀번호}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.hibernate.ddl-auto=update

spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true

 

controller>HomeController

@Controller
public class HomeController {

	@GetMapping("/")
	public String home(Model model) {
		model.addAttribute("username", "username");
		return "index";
	}
}

controller>UserController

@Controller
@RequestMapping("/api")
public class UserController {

	@GetMapping("/user/login-page")
	public String loginPage() {
		return "login";
	}

	@GetMapping("/user/signup")
	public String signupPage() {
		return "signup";
	}
}

 

src > main > resources > templates

index.html

더보기

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="preconnect" href="https://fonts.gstatic.com">

  <link rel="stylesheet" href="/css/style.css">
  <script src="https://code.jquery.com/jquery-3.7.0.min.js" integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/js-cookie@3.0.5/dist/js.cookie.min.js"></script>
  <script src="/js/basic.js"></script>
  <title>나만의 셀렉샵</title>
</head>
<body>
<div class="header" style="position:relative;">
  <div id="login-true" style="display: none">
    <div id="header-title-login-user">
      <span th:text="${username}"></span> 님의
    </div>
    <div id="header-title-select-shop">
      Select Shop
    </div>
    <a id="login-text" href="javascript:logout()">
      로그아웃
    </a>
  </div>
  <div id="login-false" >
    <div id="header-title-select-shop">
      My Select Shop
    </div>
    <a id="sign-text" href="/api/user/signup">
      회원가입
    </a>
    <a id="login-text" href="/api/user/login-page">
      로그인
    </a>
  </div>
</div>
</body>
</html>

login.html

더보기

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="preconnect" href="https://fonts.gstatic.com">
  <link rel="stylesheet" type="text/css" href="/css/style.css">
  <meta charset="UTF-8">
  <title>로그인 페이지</title>
</head>
<body>
<div id="login-form">
  <div id="login-title">Log into Select Shop</div>
  <br>
  <br>
  <button id="login-id-btn" onclick="location.href='/api/user/signup'">
    회원 가입하기
  </button>
  <form action="/api/user/login" method="post">
    <div class="login-id-label">아이디</div>
    <input type="text" name="username" class="login-input-box">

    <div class="login-id-label">비밀번호</div>
    <input type="password" name="password" class="login-input-box">

    <button id="login-id-submit">로그인</button>
  </form>
  <div id="login-failed" style="display: none" class="alert alert-danger" role="alert">로그인에 실패하였습니다.</div>
</div>
</body>
<script>
  const href = location.href;
  const queryString = href.substring(href.indexOf("?")+1)
  if (queryString === 'error') {
    const errorDiv = document.getElementById('login-failed');
    errorDiv.style.display = 'block';
  }
</script>
</html>

signup.html

더보기

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="preconnect" href="https://fonts.gstatic.com">
  <link rel="stylesheet" type="text/css" href="/css/style.css">
  <meta charset="UTF-8">
  <title>회원가입 페이지</title>
  <script>
    function onclickAdmin() {
      // Get the checkbox
      var checkBox = document.getElementById("admin-check");
      // Get the output text
      var box = document.getElementById("admin-token");

      // If the checkbox is checked, display the output text
      if (checkBox.checked == true){
        box.style.display = "block";
      } else {
        box.style.display = "none";
      }
    }
  </script>
</head>
<body>
<div id="login-form">
  <div id="login-title">Sign up Select Shop</div>

  <form action="/api/user/signup" method="post">
    <div class="login-id-label">Username</div>
    <input type="text" name="username" placeholder="Username" class="login-input-box">

    <div class="login-id-label">Password</div>
    <input type="password" name="password" class="login-input-box">

    <div class="login-id-label">E-mail</div>
    <input type="text" name="email" placeholder="E-mail" class="login-input-box">

    <div>
      <input id="admin-check" type="checkbox" name="admin" onclick="onclickAdmin()" style="margin-top: 40px;">관리자
      <input id="admin-token" type="password" name="adminToken" placeholder="관리자 암호" class="login-input-box" style="display:none">
    </div>
    <button id="login-id-submit">회원 가입</button>
  </form>
</div>
</body>
</html>

 

src > main > resources > static

css>style.css

더보기

* {
    font-family: 'Georgia', serif;
}

body {
    margin: 0px;
}

.header {
    height: 255px;
    box-sizing: border-box;
    background-color: #15aabf;
    color: white;
    text-align: center;
    padding-top: 80px;
    /*padding: 50px;*/
    font-size: 45px;
    font-weight: bold;
}

#header-title-login-user {
    font-size: 36px;
    letter-spacing: -1.08px;
}

#header-title-select-shop {
    margin-top: 20px;
    font-size: 45px;
    letter-spacing: 1.1px;
}

#login-form {
    width: 538px;
    height: 710px;
    margin: 70px auto 141px auto;
    display: flex;
    flex-direction: column;
    justify-content: flex-start;
    align-items: center;
    /*gap: 96px;*/
    padding: 56px 0 0;
    border-radius: 10px;
    box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.15);
    background-color: #ffffff;
}

#login-title {
    width: 303px;
    height: 32px;
    /*margin: 56px auto auto auto;*/
    flex-grow: 0;
    font-family: SpoqaHanSansNeo;
    font-size: 32px;
    font-weight: bold;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: -0.96px;
    text-align: left;
    color: #212529;
}

#login-kakao-btn {
    border-width: 0;
    margin: 96px 0 8px;
    width: 393px;
    height: 62px;
    flex-grow: 0;
    display: flex;
    flex-direction: row;
    justify-content: center;
    align-items: center;
    gap: 10px;
    /*margin: 0 0 8px;*/
    padding: 11px 12px;
    border-radius: 5px;
    background-color: #ffd43b;

    font-family: SpoqaHanSansNeo;
    font-size: 20px;
    font-weight: bold;
    font-stretch: normal;
    font-style: normal;
    color: #414141;
}

#login-id-btn {
    width: 393px;
    height: 62px;
    flex-grow: 0;
    display: flex;
    flex-direction: row;
    justify-content: center;
    align-items: center;
    gap: 10px;
    /*margin: 8px 0 0;*/
    padding: 11px 12px;
    border-radius: 5px;
    border: solid 1px #212529;
    background-color: #ffffff;

    font-family: SpoqaHanSansNeo;
    font-size: 20px;
    font-weight: bold;
    font-stretch: normal;
    font-style: normal;
    color: #414141;
}

.login-input-box {
    border-width: 0;

    width: 370px !important;
    height: 52px;
    margin: 14px 0 0;
    border-radius: 5px;
    background-color: #e9ecef;
}

.login-id-label {
    /*width: 44.1px;*/
    /*height: 16px;*/
    width: 382px;
    padding-left: 11px;
    margin-top: 40px;
    /*margin: 0 337.9px 14px 11px;*/
    font-family: NotoSansCJKKR;
    font-size: 16px;
    font-weight: normal;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: -0.8px;
    text-align: left;
    color: #212529;
}

#login-id-submit {
    border-width: 0;
    width: 393px;
    height: 62px;
    flex-grow: 0;
    display: flex;
    flex-direction: row;
    justify-content: center;
    align-items: center;
    gap: 10px;
    margin: 40px 0 0;
    padding: 11px 12px;
    border-radius: 5px;
    background-color: #15aabf;

    font-family: SpoqaHanSansNeo;
    font-size: 20px;
    font-weight: bold;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: normal;
    text-align: center;
    color: #ffffff;
}

#sign-text {
    position:absolute;
    top:48px;
    right:110px;
    font-size: 18px;
    font-family: SpoqaHanSansNeo;
    font-size: 18px;
    font-weight: 500;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: 0.36px;
    text-align: center;
    color: #ffffff;
}

#login-text {
    position:absolute;
    top:48px;
    right:50px;
    font-size: 18px;
    font-family: SpoqaHanSansNeo;
    font-size: 18px;
    font-weight: 500;
    font-stretch: normal;
    font-style: normal;
    line-height: 1;
    letter-spacing: 0.36px;
    text-align: center;
    color: #ffffff;
}

.alert-danger {
    color: #721c24;
    background-color: #f8d7da;
    border-color: #f5c6cb;
}

.alert {
    width: 300px;
    margin-top: 22px;
    padding: 1.75rem 1.25rem;
    border: 1px solid transparent;
    border-radius: .25rem;
}

js>basic.js

더보기

let host = 'http://' + window.location.host;

$(document).ready(function () {
    const auth = getToken();
    if(auth === '') {
        window.location.href = host + "/api/user/login-page";
    } else {
        $('#login-true').show();
        $('#login-false').hide();
    }
})

function logout() {
    // 토큰 삭제
    Cookies.remove('Authorization', { path: '/' });
    window.location.href = host + "/api/user/login-page";
}

function getToken() {
  let auth = Cookies.get('Authorization');

if(auth === undefined) {
        return '';
   }

return auth;
}

 

 

 

▼ 회원 DB에 매핑되는 @Entity 클래스 구현

entity>User

@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "users")
public class User {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

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

	@Column(nullable = false)
	private String password;

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

	@Column(nullable = false)
	@Enumerated(value = EnumType.STRING)
	private UserRoleEnum role;
}
  • @Enumerated(value = EnumType.STRING)
    • EnumType을 DB 컬럼에 저장할 때 사용하는 애너테이션
    • EnumType.STRING 옵션을 사용하면 Enum의 이름을 DB에 그대로 저장
    • USER(Authority.USER) → USER

 

  • 암호화 후 패스워드 저장이 필요
    ㄴ 평문 → (암호화 알고리즘) → 암호문
  • 복호화가 불가능한 '단방향' 암호 알고리즘 사용이 필요

 

  • 양방향 암호 알고리즘
    • 암호화: 평문 → (암호화 알고리즘) → 암호문
    • 복호화: 암호문 → (암호화 알고리즘) → 평문
  • 단방향 암호 알고리즘
    • 암호화: 평문 → (암호화 알고리즘) → 암호문
    • 복호화: 불가 (암호문 → (암호화 알고리즘) → 평문)

 

  • Password 확인절차
    1. 사용자가 로그인을 위해 "아이디, 패스워드 (평문)" 입력 → 서버에 로그인 요청
      (1) 서버에서 패스워드 (평문) 을 암호화
      (2) 평문 → (암호화 알고리즘) → 암호문

    2. DB 에 저장된 "아이디, 패스워드 (암호문)"와 일치 여부 확인
      • <Password Matching>
        Spring Security라는 프레임워크에서 제공하는 비밀번호 암호화 기능
        Bean 수동등록 예제로 봤던 PasswordEncoder가 해당 Security에서 제공하는 비밀번호 암호화 메서드
        사용자가 입력한 비밀번호를 암호화되어 저장된 비밀번호와 비교하여 일치여부를 확인해주는 기능도 가지고 있어 많이 사용
// 사용예시
// 비밀번호 확인
if(!passwordEncoder.matches("사용자가 입력한 비밀번호", "저장된 비밀번호")) {
		   throw new IllegalAccessError("비밀번호가 일치하지 않습니다.");
 }
  • boolean matches(CharSequence rawPassword, String encodedPassword);
    • rawPassword : 사용자가 입력한 비밀번호
    • encodedPassword : 암호화되어 DB 에 저장된 비밀번호

 

 

controller>UserController

@Controller
@RequestMapping("/api")
public class UserController {

	private final UserService userService;

	public UserController(UserService userService) {
		this.userService = userService;
	}


	@GetMapping("/user/login-page")
	public String loginPage() {
		return "login";
	}

	@GetMapping("/user/signup")
	public String signupPage() {
		return "signup";
	}

	@PostMapping("/user/signup")
	public String signup(SignupRequestDto requestDto) {
		userService.signup(requestDto);

		return "redirect:/api/user/login-page";
	}
}

dto>SignupRequestDto

@Getter
@Setter
public class SignupRequestDto {
	private String username;
	private String password;
	private String email;
	private boolean admin = false;
	private String adminToken = "";
}

service>UserService

@Service
public class UserService {

	private final UserRepository userRepository;
	private final PasswordEncoder passwordEncoder;

	public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
		this.userRepository = userRepository;
		this.passwordEncoder = passwordEncoder;
	}

	// ADMIN_TOKEN
	private final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";

	public void signup(SignupRequestDto requestDto) {
		String username = requestDto.getUsername();
		String password = passwordEncoder.encode(requestDto.getPassword());

		// 회원 중복 확인
		Optional<User> checkUsername = userRepository.findByUsername(username);
		if (checkUsername.isPresent()) {
			throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
		}

		// email 중복확인
		String email = requestDto.getEmail();
		Optional<User> checkEmail = userRepository.findByEmail(email);
		if (checkEmail.isPresent()) {
			throw new IllegalArgumentException("중복된 Email 입니다.");
		}

		// 사용자 ROLE 확인
		UserRoleEnum role = UserRoleEnum.USER;
		if (requestDto.isAdmin()) {
			if (!ADMIN_TOKEN.equals(requestDto.getAdminToken())) {
				throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
			}
			role = UserRoleEnum.ADMIN;
		}

		// 사용자 등록
		User user = new User(username, password, email, role);
		userRepository.save(user);
	}
}
  • '관리자 가입 토큰' 입력 필요 : 랜덤하게 생성된 토큰 사용 (ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";)
    ㄴ 현업에서는 1) '관리자' 권한을 부여할 수 있는 관리자 페이지 구현, 2) 승인자에 의한 결재 과정 구현 → 관리자 권한 부여

 

repository>UserRepository

public interface UserRepository extends JpaRepository<User,Long> {
	Optional<User> findByUsername(String username);

	Optional<User> findByEmail(String email);
}

 

entity>User (생성자 추가)

@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "users")
public class User {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

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

	@Column(nullable = false)
	private String password;

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

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

	public User(String username, String password, String email, UserRoleEnum role) {
		this.username = username;
		this.password = password;
		this.email = email;
		this.role = role;
	}
}

 

 


controller>UserController

	@PostMapping("/user/login")
	public String login(LoginRequestDto requestDto, HttpServletResponse res) {
		try {
			userService.login(requestDto, res);
		} catch (Exception e) {
			return "redirect:/api/user/login-page?error";
		}

		return "redirect:/";
	}

 

dto>LoginRequestDto

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

service>UserService

@Service
public class UserService {

	private final UserRepository userRepository;
	private final PasswordEncoder passwordEncoder;
	private final JwtUtil jwtUtil;

	public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtUtil jwtUtil) {
		this.userRepository = userRepository;
		this.passwordEncoder = passwordEncoder;
		this.jwtUtil = jwtUtil;
	}


	public void login(LoginRequestDto requestDto, HttpServletResponse res) {
		String username = requestDto.getUsername();
		String password = requestDto.getPassword();

		// 사용자 확인
		User user = userRepository.findByUsername(username).orElseThrow(
			() -> new IllegalArgumentException("등록된 사용자가 없습니다.")
		);

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

		// JWT 생성 및 쿠키에 저장 후 Response 객체에 추가
		String token = jwtUtil.createToken(user.getUsername(), user.getRole());
		jwtUtil.addJwtToCookie(token, res);
	}
}

 

 

 


  • Filter란 Web 애플리케이션에서 관리되는 영역으로, Client로부터 오는 요청과 응답에 대해 최초/최종 단계의 위치이며 이를 통해 요청과 응답의 정보를 변경하거나 부가적인 기능을 추가가능
  • 주로 범용적으로 처리해야 하는 작업들, 예를들어 로깅 및 보안 처리에 활용
    • 또한 인증, 인가와 관련된 로직들을 처리
    • Filter를 사용하면 인증, 인가와 관련된 로직을 비즈니스 로직과 분리하여 관리할 수 있다는 장점

 

 

Filter는 한 개만 존재하는 것이 아니라 이렇게 여러 개가 Chain 형식으로 묶여서 처리될 수 있음

1. Request URL Logging

filter > LoggingFilter

@Slf4j(topic = "LoggingFilter")
@Component
@Order(1)
public class LoggingFilter implements Filter {
	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
		// 전처리
		HttpServletRequest httpServletRequest = (HttpServletRequest) request;
		String url = httpServletRequest.getRequestURI();
		log.info(url);

		chain.doFilter(request, response); // 다음 Filter 로 이동

		// 후처리
		log.info("비즈니스 로직 완료");
	}
}
  • @Order(1) 로 필터의 순서 지정
  • chain.doFilter(request, response); 다음 Filter로 이동시킴
  • log.info("비즈니스 로직 완료");
    • 작업이 다 완료된 후 Client에 응답 전 로그가 작성된 것을 확인가능

 

2. AuthFilter : 인증 및 인가 처리 필터

filter > AuthFilter

@Slf4j(topic = "AuthFilter")
@Component
@Order(2)
public class AuthFilter implements Filter {

    private final UserRepository userRepository;
    private final JwtUtil jwtUtil;

    public AuthFilter(UserRepository userRepository, JwtUtil jwtUtil) {
        this.userRepository = userRepository;
        this.jwtUtil = jwtUtil;
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String url = httpServletRequest.getRequestURI();

        if (StringUtils.hasText(url) &&
                (url.startsWith("/api/user") || url.startsWith("/css") || url.startsWith("/js"))
        ) {
            // 회원가입, 로그인 관련 API 는 인증 필요없이 요청 진행
            chain.doFilter(request, response); // 다음 Filter 로 이동
        } else {
            // 나머지 API 요청은 인증 처리 진행
            // 토큰 확인
            String tokenValue = jwtUtil.getTokenFromRequest(httpServletRequest);

            if (StringUtils.hasText(tokenValue)) { // 토큰이 존재하면 검증 시작
                // JWT 토큰 substring
                String token = jwtUtil.substringToken(tokenValue);

                // 토큰 검증
                if (!jwtUtil.validateToken(token)) {
                    throw new IllegalArgumentException("Token Error");
                }

                // 토큰에서 사용자 정보 가져오기
                Claims info = jwtUtil.getUserInfoFromToken(token);

                User user = userRepository.findByUsername(info.getSubject()).orElseThrow(() ->
                        new NullPointerException("Not Found User")
                );

                request.setAttribute("user", user);
                chain.doFilter(request, response); // 다음 Filter 로 이동
            } else {
                throw new IllegalArgumentException("Not Found Token");
            }
        }
    }

}
  • httpServletRequest.getRequestURI() : 요청 URL을 가져와서 구분 (인가)
    • "/api/user", "/css", "/js" 로 시작하는 URL은 인증 처리에서 제외
  • 그 외 URL은 인증 처리를 진행
    • jwtUtil.getTokenFromRequest(httpServletRequest);
      • httpServletRequest 에서 Cookie 목록을 가져와 JWT가 저장된 Cookie를 찾음
      • getTokenFromRequest 메서드를 JwtUtil에 구현

jwt>JwtUtil

	// HttpServletRequest 에서 Cookie Value : JWT 가져오기
	public String getTokenFromRequest(HttpServletRequest req) {
		Cookie[] cookies = req.getCookies();
		if(cookies != null) {
			for (Cookie cookie : cookies) {
				if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
					try {
						return URLDecoder.decode(cookie.getValue(), "UTF-8"); // Encode 되어 넘어간 Value 다시 Decode
					} catch (UnsupportedEncodingException e) {
						return null;
					}
				}
			}
		}
		return null;
	}
  • tokenValue가 존재하면 토큰 파싱, 검증을 진행하고 사용자 정보를 가져옴
  • 가져온 사용자 username을 사용해서 DB에 사용자가 존재하는지 확인하고 존재하면 인증이 완료된 것임
  • 사용자 정보가 필요한 Controller API에 인증완료된 User 객체를 전달해줌

 

controller>ProductController

@Controller
@RequestMapping("/api")
public class ProductController {

	@GetMapping("/products")
	public String getProducts(HttpServletRequest req) {
		System.out.println("ProductController.getProducts : 인증 완료");
		User user = (User) req.getAttribute("user");
		System.out.println("user.getUsername() = " + user.getUsername());

		return "redirect:/";
	}
}
  • 사용자 본인이 등록한 제품만 조회하는 기능의 API라 가정
  • Filter에서 인증 처리되어 넘어온 User 객체를 사용하면 API 요청을 한 해당 사용자가 등록한 제품만 조회가능
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.