241220 인증(Authentication) / 인가(Authorization)

241220 에이콘 아카데미 수업에 기반하여 작성되었음을 알립니다.

스프링 시큐리티

인증(Authentication) : 
인증은 사용자의 신원을 입증하는 과정이다.
간단히 말해 어떤 사이트에 아이디와 비밀번호 입력하여 로그인 하는 과정이다.

인가(Authorization) : 
'권한부여'나 '허가'와 같은 의미로 사용된다. 즉, 어떤 대상이 특정 목적을 실현하도록 허용하는 것을 말한다. 예를 들면, 파일 공유 시스템에서 권한별로 접근할 수 있는 폴더가 상이하다. 관리자는 접속이 가능하지만 일반 사용자는 접속할 수 없는 경우에서 사용자의 권한을 확인하게 되는데, 이 과정을 인가라고 한다.

스프링 시큐리티를 적용할 때 세션만 쓸 때의 장단점, JWT와 함께 적용할 때의 장단점이 있다.

스프링 시큐리티는 필터 기반으로 동작한다.
Spring Security는 '인증'과 '권한'에 대한 부분을 Filter 흐름에 따라 처리하고 있다.

Filter는 DispatcherServlet을 만나기 전에 적용되므로 가장 먼저 URL 요청을 받지만, Interceptor는 Dispatcher와 Controller 사이에 위치한다는 점에서 적용 시기의 차이가 있다. 그러므로 Spring Security는 보안과 관련하여 다양한 옵션을 제공해주기 때문에 개발자는 일일이 보안 관련 로직을 작성하지 않아도 된다는 장점이 있다.

Model 1 - Session

Spring Security 없이 세션과, JWT를 사용하여 실습, 기본 원리를 알고 Security에 들어가기 위함이다.

Dynamic Web Project

 

login.html

(webapp에 생성)

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>session login</h2>
<form action="login.jsp" method="post">
id : <input type="text" name="id"><br/>
pw : <input type="text" name="password"><br/>
<br/>
<input type="submit" value="로그인">
</form>
</body>
</html>

login.jsp

Servlet으로 만들어도 괜찮다.

JSP는 처음 실행만 느리고 다음부터는 Servlet으로 바뀌기 때문에 빠른 속도로 실행 가능

관리자가 처음 실행만 해주면 문제없다.

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%
String id = request.getParameter("id");
String password = request.getParameter("password");

// 자격 증명 확인
String validId = "yyummmmmmmm";
String validPw = "1111";

// 인증(Authentication)
if (id != null && password != null && id.equals(validId) && password.equals(validPw)) {
	HttpSession httpSession = request.getSession();
	httpSession.setAttribute("userId", id);
	
	response.sendRedirect("success.jsp");
} else {
	// 자격 증명이 유효하지 않은 경우 오류 메세지 출력
	out.println("<html><body>");
	out.println("<h3>login fail</h3>");
	out.println("<a href='login.html'>retry login</a>");
	out.println("</body></html>");	
}
%>

인증 실패

success.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%
// 세션으로 확인
HttpSession httpSession = request.getSession(false);

if (httpSession != null && httpSession.getAttribute("userId") != null) {
	String userId = (String)httpSession.getAttribute("userId");
%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
	<h2>인가(Authorization) 성공!</h2>
	<br/>
	<p>쇼핑 페이지를 즐겨라</p>
	<a href="logout.jsp">로그아웃</a>
</body>
</html>
<%
} else {
	response.sendRedirect("login.html");
}
%>

다른 브라우저는 인증을 받지 못했기 때문에 세션이 없어서 success.jsp에 접근 불가능하다.

해당 세션(9CF219FE893585C05A496515D02E719D)은 우리가 만든 세션이 아님

세션을 인증이 완료된 브라우저 세션에 넣어보자!

다시 접근해주면 접근 가능!, 세션만 사용하는 방식의 맹점, 보안에 문제가 있다.

logout.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%
HttpSession httpSession = request.getSession(false);
httpSession.removeAttribute("userId");

response.sendRedirect("login.html");
%>

로그아웃, 현재 보이는 쿠키에 남아있는 세션은 흔적만 남겨져 있는 것이다.

다시 접근 시도

접근 실패, login.html로 리다이렉트됨

클라이언트측에 남은 흔적 지우기

// 클라이언트 쿠키에 남아있는 JSESSIONID 삭제 (선택적)
Cookie cookie = new Cookie("JSESSIONID", null);
cookie.setMaxAge(0);
cookie.setPath(request.getContextPath());
response.addCookie(cookie);

다시 로그인

로그아웃 시 흔적 사라짐


JWT (Json Web Token)

정보를 비밀리에 전달하거나 인증할 때 주로 사용하는 토큰으로, Json 객체를 이용한다. JWT는 Json Web Token의 약자로 일반적으로 클라이언트와 서버 사이에서 통신할 때 권한을 위해 사용하는 토큰이다.
Session의 단점을 일부 해결할 수 있는 기술중 하나로 JWT를 사용한다.

JWT 구성

- 헤더(Header) : 토큰 유형과 서명 알고리즘을 포함한다. JWT에서 사용할 타입과 해시 알고리즘의 종류
{ "alg": "HS256", "typ": "JWT" }
alg : Signature에서 사용하는 알고리즘
typ : 토큰 타입
Signature에서 사용하는 알고리즘은 대표적으로 RS256(공개키/개인키)와 HS256(비밀키(대칭키))가 있다. 이 부분은 auth0(https://auth0.com/docs/get-started/applications/signing-algorithms) 공식 문서에서 확인할 수 있다.

- 페이로드(Payload) : 클레임(Claims)이라고 하는 인증 정보와 기타 데이터를 포함한다. 서버에서 첨부한 사용자 권한 정보와 사용자의 데이터. "...": "..."(key-value) 한 쌍을 Claim이라고 한다.
{ "sub": "1234567890", "name": "John Doe", "admin": true }
사용자 정보의 한 조각인 클레임(claim)이 들어있다.
sub : 토큰 제목(subject)
aud : 토큰 대상자(audience)
iat : 토큰이 발급된 시각 (issued at)
exp : 토큰의 만료 시각 (expired)

- 서명(Signature) : 토큰의 무결성을 검증하기 위해 사용된다. Header, Payload 를 Encode한 이후 Header 에 명시된 해시함수를 적용하고, Private Key로 서명한 전자서명으로 (헤더 + 페이로드)와 서버가 갖고 있는 유일한 key 값을 합친 것을 헤더에서 정의한 알고리즘으로 암호화를 한다.
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
Signature는 헤더와 페이로드의 문자열을 합친 후에, 헤더에서 선언한 알고리즘과 key를 이용해 암호한 값이다. Header와 Payload는 단순히 Base64url로 인코딩되어 있어 누구나 쉽게 복호화할 수 있지만, Signature는 key가 없으면 복호화할 수 없다. 이를 통해 보안상 안전하다는 특성을 가질 수 있게 되었다. header에서 선언한 알고리즘에 따라 key는 개인키가 될 수도 있고 비밀키가 될 수도 있다. 개인키로 서명했다면 공개키로 유효성 검사를 할 수 있고, 비밀키로 서명했다면 비밀키를 가지고 있는 사람만이 암호화 복호화, 유효성 검사를 할 수 있다.

 

access token & refresh token

이 방식은 토큰들에 유효기간을 주어서 보안을 강화시킨 것이다. access token은 유효기간이 짧은 토큰이고, refresh token은 access token보다 유효기간이 긴 토큰이다.

 - 사용자가 로그인을 하면 서버로부터 access token, refresh token 2개의 토큰을 받는다. 이때 서버는 refresh token을 안전한 저장소에 저장한다. 클라이언트마다 고유의 refresh token을 가지고 있다.

 - 서버로부터 받은 access token의 유효 기간이 지나지 않은 경우 사용자가 어떤 요청을 보낼 때 access token을 함께 보내고 서버는 유효한 지 확인 후 응답을 보낸다.

 - 서버로부터 받은 access token의 유효 기간이 지난 경우 사용자는 refresh token과 함께 요청을 보내고, 저장소에 저장되어 있던 refresh token과 비교한 후에 유효하다면 새로운 access token과 응답을 함께 보낸다.

💡JWT 구조 확인
https://jwt.io/ 에 들어가 token 값을 입력하면 현재 자신이 사용 중인 JWT 토큰이 어떤 구조로 되어 있는지 쉽게 눈으로 확인할 수 있다.

 

JWT 장점

  • 데이터의 위변조를 방지할 수 있다.
  • JWT는 인증에 필요한 모든 정보를 담고 있기 때문에 인증을 위한 별도의 저장소가 없어도 된다.
  • 세션(Stateful)과 다르게 서버는 무상태(StateLess)가 된다.
  • 확장성이 우수하다.
  • 토큰 기반으로 다른 로그인 시스템에 접근 및 권한 공유가 가능하다.(쿠키와의 차이)
  • OAuth의 경우 소셜 계정을 통해서 다른 웹서비스에 로그인 할 수 있다.
  • 모바일에서도 잘 동작한다.(세션은 모바일 x)

JWT 단점

  • 쿠키/세션과 다르게 토큰의 길이가 길어, 인증 요청이 많아질수록 네트워크 부하가 심해진다.
  • Payload 자체는 암호화가 되지 않아 중요한 정보는 담을 수 없다.
  • 토큰을 도난 당하면 이에 대한 대처가 어렵다.

Model1 - JWT

JWT 라이브러리 넣어주기!

 

위에서 작성했던 html, jsp를 복사해서 수정!

login.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>JWT login</h2>
<form action="login.jsp" method="post">
id : <input type="text" name="id"><br/>
pw : <input type="text" name="password"><br/>
<br/>
<input type="submit" value="로그인">
</form>
</body>
</html>

login.jsp

<%@page import="io.jsonwebtoken.SignatureAlgorithm"%>
<%@page import="java.util.Date"%>
<%@page import="io.jsonwebtoken.Jwts"%>
<%@page import="java.security.Key"%>
<%@page import="io.jsonwebtoken.security.Keys"%>
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%
String id = request.getParameter("id");
String password = request.getParameter("password");

// 자격 증명 확인
String validId = "yyummmmmmmm";
String validPw = "1111";

// 인증(Authentication)
if (id != null && password != null && id.equals(validId) && password.equals(validPw)) {
	// JWT 생성 후 클라이언트 쿠키에 저장
	// 고정된 비밀 키 사용 (예제용)  최소 256비트 길이의 비밀 키
	String secretKeyString = "mySuperSecretKey12345678901234567890123456789012";
	Key secretKey = Keys.hmacShaKeyFor(secretKeyString.getBytes());
	
	// 토큰 생성
	String jwt = Jwts.builder()
	                .setSubject(id) // 토큰 용도(제목)
	                .setIssuedAt(new Date()) // 생성 시간 설정
	                .setExpiration(new Date(System.currentTimeMillis() + 3600000)) // 토큰 만료 시간 설정. 1시간 유효
	                .signWith(SignatureAlgorithm.HS256, secretKey) // HS256과 Key로 Sign
	                .compact();  // 토큰 생성
	
	// JWT 쿠키에 저장
	Cookie jwtCookie = new Cookie("jwt", jwt);
	jwtCookie.setHttpOnly(true); // XSS 방지 목적, 클라이언트에서 쿠키에 수정 불가, readonly 상태로 만듬
	jwtCookie.setPath("/"); // 누구든지 접근 가능
	//jwtCookie.setDomain("aa.com"); // 특정 도메인만 허용
	response.addCookie(jwtCookie);	                
	                
	response.sendRedirect("success.jsp");
} else {
	// 자격 증명이 유효하지 않은 경우 오류 메세지 출력
	out.println("<html><body>");
	out.println("<h3>login fail</h3>");
	out.println("<a href='login.html'>retry login</a>");
	out.println("</body></html>");	
}
%>

JWT 생성 후 클라이언트 쿠키에 저장

jwt.io에서 확인

페이로드 정보를 바꿔주면 JWT 토큰이 수정됨

 

비밀키 설정

JWT 토큰을 생성할 때마다 다른 비밀키로 암호화해준다.

InitServlet.java

package pack;

import javax.servlet.*;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import javax.crypto.SecretKey;
import javax.crypto.KeyGenerator;
import java.util.Base64;

@WebServlet(loadOnStartup = 1)
public class InitServlet extends HttpServlet {
	
	public void init() throws ServletException {
	
        try {
            // 비밀 키 생성
            KeyGenerator keyGen = KeyGenerator.getInstance("HmacSHA256");
            keyGen.init(256); // 256비트 길이의 키 생성
            SecretKey secretKey = keyGen.generateKey();
            /*
            실제 비밀 키는 KeyGenerator 객체가 생성한 후 SecretKey 객체로 반환된다. 
            이 SecretKey 객체는 메모리에 존재하며, 파일이나 데이터베이스와 같은 외부 저장소에 
            저장하려면 명시적으로 저장하는 코드를 작성해야 한다.
             */

            // 비밀 키를 Base64로 인코딩하여 문자열로 변환  java.util.Base64
            String encodedKey = Base64.getEncoder().encodeToString(secretKey.getEncoded());
            System.out.println("encodedKey : " + encodedKey);
            // 서블릿 컨텍스트에 비밀 키 저장
            getServletContext().setAttribute("secretKey", encodedKey);
        } catch (Exception e) {
            throw new ServletException("키 생성 오류", e);
        }
    }
}

web.xml

아래 코드를 추가

<servlet>
	<servlet-name>InitServlet</servlet-name>
	<servlet-class>pack.InitServlet</servlet-class>
	<load-on-startup>1</load-on-startup>
</servlet>

login.jsp 수정

<%@page import="java.util.Base64"%>
<%@page import="io.jsonwebtoken.SignatureAlgorithm"%>
<%@page import="java.util.Date"%>
<%@page import="io.jsonwebtoken.Jwts"%>
<%@page import="java.security.Key"%>
<%@page import="io.jsonwebtoken.security.Keys"%>
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%
String id = request.getParameter("id");
String password = request.getParameter("password");

// 자격 증명 확인
String validId = "yyummmmmmmm";
String validPw = "1111";

// 인증(Authentication)
if (id != null && password != null && id.equals(validId) && password.equals(validPw)) {
    // JWT 생성 후 클라이언트 쿠키에 저장
    // 고정된 비밀 키 사용 (예제용)  최소 256비트 길이의 비밀 키
    // String secretKeyString = "mySuperSecretKey12345678901234567890123456789012";
    // Key secretKey = Keys.hmacShaKeyFor(secretKeyString.getBytes());

    // 위의 작업을 주석 처리하고 아래 내용으로 변경하자.
    // 서블릿 컨텍스트에서 Base64로 인코딩된 비밀 키 가져오기  java.util.Base64
    String encodedKey = (String) getServletContext().getAttribute("secretKey");
    byte[] decodedKey = Base64.getDecoder().decode(encodedKey);
    Key secretKey = Keys.hmacShaKeyFor(decodedKey);
	
	... 생략
%>

정상 작동, success.jsp에서 검증을 안해줘서 다시 login.html로 리다이렉트됨

success.jsp

<%@page import="io.jsonwebtoken.Jwts"%>
<%@page import="io.jsonwebtoken.Claims"%>
<%@page import="io.jsonwebtoken.Jws"%>
<%@page import="java.security.Key"%>
<%@page import="io.jsonwebtoken.security.Keys"%>
<%@page import="java.util.Base64"%>
<%@page import="io.jsonwebtoken.ExpiredJwtException"%>
<%@page import="io.jsonwebtoken.JwtException"%>
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%
// JWT으로 확인
// 쿠키에서 JWT 읽기
Cookie[] cookies = request.getCookies(); // 클라이언트의 모든 쿠키를 읽어온 후 JWT 토큰을 찾아야 함
String jwt = null;

if (cookies != null) {
	for(Cookie cookie:cookies) {
		if(cookie.getName().equals("jwt")){
			jwt = cookie.getValue();
			break;
		}
	}
}

if (jwt != null) {
	try {
		String encodedKey = (String) getServletContext().getAttribute("secretKey");
	    byte[] decodedKey = Base64.getDecoder().decode(encodedKey);
	    Key secretKey = Keys.hmacShaKeyFor(decodedKey);
	    
	    // 유효성 검사
	    Jws<Claims> claims = Jwts.parserBuilder()
	    	.setSigningKey(secretKey)
	    	.build()
	    	.parseClaimsJws(jwt);
	    	// 전달된 JWT를 파싱 후 유효하면 Jws<Claims> 객체를 반환
	    	// 유효하지 않다면 ExpiredJwtException, JwtException 예외를 반환한다.
	    	
	    	String userId = claims.getBody().getSubject(); // sub claim 읽기
	    	
	    	// JWT가 유효한 경우 메세지 출력	    	
%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
	<h2>인가(Authorization) 성공!</h2>
	<br/>
	<p><%=userId %>님 쇼핑 페이지를 즐겨라</p>
	<a href="logout.jsp">로그아웃</a>
</body>
</html>
<%
	} catch (ExpiredJwtException e) {
		System.out.println("만료된 토큰");
		response.sendRedirect("login.html");
	} catch (JwtException e) {
		System.out.println("유효하지 않은 토큰");
		response.sendRedirect("login.html");
	}
} else {
	response.sendRedirect("login.html");
}
%>

유저 정보까지 출력!

logout.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%
// 클라이언트 쿠키에 남아있는 JWT 삭제 (선택적)
Cookie cookie = new Cookie("jwt", null);
cookie.setMaxAge(0);
cookie.setPath(request.getContextPath());
response.addCookie(cookie);

response.sendRedirect("login.html");
%>

🙏 Cookie와 Session, JWT


SpringBoot - Session

Project 생성

application.properties

spring.application.name=sprsessionlogin

server.port=8080

spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html

index.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
메인<br/>
<a href="/login">로그인</a>
<a href="/gugu">구구단</a>
</body>
</html>

LoginController

package pack.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class LoginController {
	
	@GetMapping("/login")
	public String login() {
		return "login";
	}
}

login.html

(templates 생성)

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>세션으로 로그인 연습</h2>
<form th:action="@{/login}" method="post">
	ID : <input type="text" name="userid"><br/>
	PW : <input type="text" name="password"><br/>
	<input type="submit" value="확인">
</form>
</body>
</html>

LoginController

package pack.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import jakarta.servlet.http.HttpSession;

@Controller
public class LoginController {
	
    ... 생략
	
	@PostMapping("login")
	public String login(@RequestParam(name = "userid")String userid,
			@RequestParam(name = "password")String password,
			HttpSession session, Model model) {
		
		String validid = "yyummmmmmmm";
		String validpw = "1111";
		
		if (userid.equals(validid) && password.equals(validpw)) {
			session.setAttribute("user", userid);
			return "redirect:/success";
		} else {
			model.addAttribute("error", "입력 정보 오류");
			return "login";
		}	
	}
}

login.html 수정

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>세션으로 로그인 연습</h2>
<form th:action="@{/login}" method="post">
	ID : <input type="text" name="userid"><br/>
	PW : <input type="text" name="password"><br/>
	<input type="submit" value="확인">
</form>

<div style="color: red">
<th:block th:if="${error}">
[[${error}]]
</th:block>
</div>
</body>
</html>

LoginController

package pack.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import jakarta.servlet.http.HttpSession;

@Controller
public class LoginController {
	
	... 생략
	
	@GetMapping("/success")
	public String success(HttpSession session, Model model) {
		String user = (String)session.getAttribute("user");
		if(user != null) {
			model.addAttribute("myuser", user);
			return "success";
		} else {
			return "redirect:/login";
		}
	}
}

success.html

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>로그인 성공</h2>
<p>환영! [[${myuser}]]야</p>
즐겨봐라 <a th:href="@{/gugu}">구구단</a>
<pre>
.
.
</pre>
<a th:href="@{/logout}">로그아웃</a>
</body>
</html>

LoginController

package pack.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import jakarta.servlet.http.HttpSession;

@Controller
public class LoginController {
	
 	... 생략
    
	@GetMapping("/logout")
	public String logout(HttpSession session) {
		session.removeAttribute("user");
		return "redirect:/login";
	}
	
	@GetMapping("/gugu")
	public String gugu(HttpSession session) {
		if (session.getAttribute("user") != null) {
			return "gugu";
		} else {
			return "redirect:/login";
		}		
	}
	
	@PostMapping("gugu")
	public String gugu(@RequestParam(name = "dan")int dan,
			HttpSession session, Model model) {
		
		if (session.getAttribute("user") != null) {
			model.addAttribute("dan", dan);
			return "guguresult";
		} else {
			return "redirect:/login";
		}
	}
}

gugu.html

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
구구단</p>
<form th:action="@{/gugu}" method="post">
단 입력 : <input type="text" name="dan" value="2" required/><br/>
<input type="submit" value="확인">
</form>
</body>
</html>

guguresult.html

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>구구단 [[${dan}]]단 결과</h2>
<ul>
	<li th:each="i:${#numbers.sequence(1, 9)}">
	[[${dan}]] x [[${i}]] = [[${dan*i}]] 
	</li>
</ul>
<br/>
<a th:href="@{/gugu}">다시 입력</a>
<br/>
<a th:href="@{/success}">로그인 성공 페이지</a>
</body>
</html>

✨ 느낀 점

세션으로 인증, 인가 처리를 하면 컨트롤러에서 클라이언트 세션 정보를 항상 받아줘야한다.
불편하다. 시큐리티를 무조건 사용해줘야겠다.