[Spring] id/pw를 활용한 로그인 / 권한 처리

IRISH·2024년 12월 20일
0

Security

목록 보기
3/3
post-thumbnail

레퍼런스

→ 쿠키, 세션

→ java의 HttpSession ⇒ JSESSIONID

프로젝트 계기

→ 오픈소스 외에는, 직접 ID/PW를 활용하여 각 User의 역할 기준으로 서비스(페이지)를 이용할 수 있는 접근법을 어떻게 하는가는 공부해본 적이 없었음

  • 그렇기 때문에, 한번 공부해보고 싶었음

프로젝트 정보

  • Java 17
  • IntelliJ IDEA Ultmate
  • Maven
  • SpringBoot
    • 3.4.0
  • DB
    • Maria DB
  • HTTP Session
    • Session Memory + Cookies

user에 대한 정보 구성

  • id(pk), username, password, role로 구분
    • pk인 id는 DB에서 Long 타입
    • username이 우리가 일반적으로 아는 사이트에서의 id에 해당하는 느낌임
    • password는 일단 평문으로 저장 예정
    • role → ‘user’와 ‘admin’으로 구분
      • ‘user’ = 일반 사용자
      • ‘admin’ = 관리자

HTTP의 특성

사용자가 회원가입을 하고 나서, 로그인을 통해 id / pw를 기입하고 로그인 처리가 됐다고 해서, 애플리케이션에서 제공하는 모든 서비스를 다 이용할 수 있다???(권한 이런 거 생각하지 말고, 순수히) ⇒ x

왜 x냐? HTTP의 특성 때문이다. HTTP의 특성은 크게 2가지가 있다.

위와 같이 HTTP의 특성으로 비연결지향과 상태없음이 있기 때문에, User가 로그인 화면을 통해서 로그인 처리가 OK 됐다고 해서, 어느 화면이나 어느 서비스나 다 자연스럽게 사용할 수 있다고 생각하면 안된다.

그런데, 애플리케이션에서는 로그인 이후에도 User가 로그인 상태를 유지한 채로 서비스를 이용할 수 있게 하는 것일까? 그것은 바로 ‘세션’ 또는 ‘쿠키’를 이용하기 때문이다.

세션과 쿠키

위에서 말했든, 로그인 이후에도 User가 로그인 상태를 유지한 채로 서비스를 이용할 수 있게 하기 위해 ‘세션’ 또는 ‘쿠키’를 이용한다고 말했다. 좀 더 개발자스럽게 이야기를 하면, 아래와 같다.

  • HTTP 특징(비연결지향 && 상태없음)으로 인해 서버는 클라이언트의 상태를 알 수 없다.
  • 클라이언트의 상태를 알아야 될 경우 ( ex. 인증 ) 쿠키와 세션을 사용한다.

그렇다. 위와 같이, 애플리케이션은 클라이언트의 로그인 상태(로그아웃 전까지)를 지속적으로 알아야 하기 때문에 쿠키와 세션을 사용하는 것이다.

여기서는, 쿠키와 세션 자체가 무엇이냐를 알아가는 포스팅이 아니기 때문에, 쿠키와 세션 자체는 다른 블로그나 책을 참고하도록 하자.

대신, 여기서는 쿠키를 활용한 로그인 구현 방식과, 세션을 활용한 로그인 구현 방식을 살펴보도록 하자.

쿠키를 이용한 로그인 예시

→ 로그인

  1. 클라이언트에서 로그인 정보(ID, password)를 서버에 전송한다.
  2. 서버는 로그인 로직을 처리하고 올바른 로그인이면 쿠키를 생성하고 회원 객체의 ID(memberId)를 담아 클라이언트에 전달한다.
  3. 클라이언트는 쿠키 저장소에 memberId 쿠키를 저장한다.

→ 로그인 상태

  1. 클라이언트는 요청시 memberId라는 쿠키를 전달한다.
  2. 서버는 클라이언트가 전달한 memberId 값으로 member를 조회하여 응답한다.

→ 쿠키의 단점

  • 텍스트 정보만 저장 가능
  • 브라우저 의존적
    • 사용자가 쿠키를 비활성화하면 쿠키를 사용할 수 없음
  • 각 쿠키 데이터는 4 KB를 초과할 수 없음
  • 보안에 취약
    • 쿠키 값은 클라이언트에서 쉽게 변경할 수 있음
    • 쿠키에 저장한 값이 DB에 저장된 본래의 값과 동일 ⇒ 한 번 훔치면 계속해서 사용할 수 있음

쿠키도 좋아 보이기는 하지만, 여러 단점이 존재한다.

위에 기재된 단점처럼 브라우저에 의존적이고, 용량도 한계가 있고, 결정적으로 보안에 취약하다.

그렇다면… 쿠키가 아닌 세션을 이용한 로그인은 어떻게 처리되는지 한번 보도록 하자.

세션를 이용한 로그인 예시

→ 로그인

  1. 클라이언트에서 로그인 정보(ID, password)를 서버에 전송한다.
  2. 서버는 로그인 로직을 처리하고 올바른 로그인이면 mySessionId 쿠키를 생성한다.
    • mySessionId 값으로 추정 불가능한 랜덤 값을 할당한다.
    • 자바의 UUID(Universally unique identifier)를 사용하면 확실한 랜덤 값을 얻을 수 있다. (중복 가능성 희박)
  3. 서버의 세션 저장소에 mySessionId와 member를 묶어 보관하고 클라이언트에는 mySessionId만 전달한다.
  4. 클라이언트는 쿠키 저장소에 mySessionId를 저장한다.

→ 로그인 상태

  1. 클라이언트는 요청시 mySessionId라는 쿠키를 전달한다.
  2. 서버는 클라이언트가 전달한 mySessionId의 쿠키 값으로 세션 저장소에서 member를 조회하여 응답한다.

→ 세션 정리

  1. 클라이언트는 회원(member)과 관련된 중요한 정보를 갖고있지 않다.
  2. 클라이언트의 세션 ID(mySessionId)는 추정 불가능한 값이기 때문에 세션 ID를 이용해서 회원과 관련된 값을 가져올 수 없다.
  3. 세션 ID는 일정 시간이 지나면 세션을 종료시키고 새로운 세션 ID를 발급할 수 있기 때문에 보안을 강화할 수 있다. (로그인 후 동작 없이 일정 시간이 지나면 자동 로그아웃 되는 것을 생각해보자)
  4. 세션 방식 또한 쿠키를 통해 클라이언트-서버가 연결된다.

쿠키를 이용한 로그인과 달리, 세션을 이용한 로그인은 상대적으로 더 좋아보인다.

필자도, 쿠키가 아닌 세션을 이용한 로그인 방식을 이용해서, 로그인 이후, 각 User의 권한에 따라 서비스를 다르게 이용하게 하고자 한다.

Java / Spring 에서는 세션을 어떻게 처리할까?

바로, HttpSession 이라는 것을 활용한다.

개념

서블릿 → 세션 트래킹을 위해 HttpSession이라는 인터페이스를 지원

  • 서블릿이 HttpSession 객체를 생성 → JSESSIONID이라는 쿠키를 생성 && 추정 불가능한 랜덤 값을 할당
  • 애플리케이션은 유저마다 다른 JSESSIONID를 부여 ⇒ JSESSIONID를 확인하여 어떤 유저의 요청인지 구분할 수 있음
  • HttpSession의 값은 서버의 메모리(RAM)에 보관

주요 메서드

MethodDescription
public HttpSession getSession()HttpSession 객체를 반환한다. 만약 요청에 관련된 세션이 없는 경우 새 세션을 생성한다.
public HttpSession getSession(boolean create)getSession()에 인자로 false를 전달하는 경우 기존 세션이 없어도 새로 생성하지 않는다. true를 전달하면 새로 생성한다.
public String getId()고유한 세션 ID를 반환한다.
public void invalidate()세션을 무효화한다.

장점

  • 텍스트 뿐 아니라 데이터 셋 등 어느 타입도 저장 가능
  • 브라우저에 의존 X
  • 안전함

단점

  • 서버에 저장되는 세션 객체로 인한 성능 오버헤드
  • 데이터 직렬화 및 역직렬화로 인한 오버헤드

위와 같이, HttpSession을 활용해 Spring은 Session 처리를 하는 것을 알 수 있다. 단점에서 보이듯, 세션은 서버에 저장되기 때문에 서버에 문제를 초래할 수 있다.

하지만, 일단 이번 포스팅에서는 세션을 활용해서 처리를 해보고자 한다.

구현

application.properties

spring.application.name=session-auth-app

# Server Port
server.port=포트번호

# MariaDB Configuration
spring.datasource.url=jdbc:mariadb://localhost:3306/session_auth_app
spring.datasource.username=데이터베이스 아이디
spring.datasource.password=데이터베이스 비밀번호
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver

# Hibernate Configuration
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect

# ViewResolver Prefix & Suffix
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html

User.java

package com.example.sessionauthapp.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private String role; // 사용자 권한 (e.g., user, admin)

    // Getter and Setter
    .
    .
    .
}
  • 위와 같이, role이라는 필드가 있고, 해당 필드에는 user 또는 admin이 들어간다.

UserRepository.java

package com.example.sessionauthapp.repository;

import com.example.sessionauthapp.model.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository  extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}
  • 동일한 username이 있는지 확인하기 위한 메서드 findByUsername

AuthController.java

package com.example.sessionauthapp.controller;

import com.example.sessionauthapp.model.User;
import com.example.sessionauthapp.repository.UserRepository;
import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
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 java.util.Optional;

@Controller
// @RestController // 이 친구는 html을 찾아주는 게 아니라 JSON이나 문자열을 반환해줌
public class AuthController {

    @Autowired
    private UserRepository userRepository;

    // wow!
    @GetMapping("/hello")
    public String helloPage(HttpSession session, Model model) {
        String username = (String) session.getAttribute("username");
        model.addAttribute("username", username);
        System.out.println("someone connects this Hello Page!!!");
        return "hello"; // hello-page
    }

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

    @PostMapping("/login")
    public String login(@RequestParam String username,
                        @RequestParam String password,
                        HttpSession session,
                        Model model) {
        Optional<User> user = userRepository.findByUsername(username);
        if (user.isPresent() && user.get().getPassword().equals(password)) {
            /*
            > 로그인이 정상적으로 처리되면, JSESSIONID라는 이름으로 세션을 쿠키에 저장
            > JSESSIONID의 역할
            - JSESSIONID는 서버가 클라이언트를 식별하기 위해 사용하는 세션 ID입니다.
            - 실제 사용자 데이터(username, role)는 *** 서버 메모리에 저장 ***되며, JSESSIONID를 통해 서버가 해당 세션 데이터를 찾습니다.
            - 개발자 도구에서는 JSESSIONID 값만 보이고, username이나 role과 같은 세부 데이터는 보이지 않습니다.
            */
            session.setAttribute("username", username);
            session.setAttribute("role", user.get().getRole());
            return "redirect:/hello";
        } else {
            model.addAttribute("error", "ID 또는 비밀번호가 틀렸습니다.");
            return "login";
        }
    }

    @GetMapping("/logout")
    public String logout(HttpSession session) {
        session.invalidate();
        return "redirect:/hello";
    }

    @GetMapping("/register")
    public String registerPage() {
        return "register";
    }

    // 회원가입 처리
    @PostMapping("/register")
    public String register(@RequestParam String username,
                           @RequestParam String password,
                           Model model) {

        // 1. 중복 아이디 확인
        Optional<User> existingUser = userRepository.findByUsername(username);
        if (existingUser.isPresent()) { // 이미 존재하는 아이디
            model.addAttribute("error", "이미 존재하는 아이디입니다.");
            return "register"; // 에러 메시지와 함께 회원가입 페이지로 반환
        }

        // 2. 사용자 정보 저장
        User user = new User();
        user.setUsername(username);
        user.setPassword(password); // 비밀번호는 추후 암호화 필요
        user.setRole("user"); // 기본 권한 설정

        userRepository.save(user); // DB에 저장

        // 3. 회원가입 성공 시 로그인 페이지로 리다이렉트
        return "redirect:/hello";
    }
    
    @GetMapping("/serviceA")
    public String serviceAPage(HttpSession session, Model model) {
        String username = (String) session.getAttribute("username");
        String role = (String) session.getAttribute("role");

        model.addAttribute("username", username);
        model.addAttribute("role", role);

        System.out.println("username = " + username +
                " & role" + role + " > connects this serviceA Page!!!");
        return "serviceA";
    }

    @GetMapping("/serviceB")
    public String serviceBPage(HttpSession session, Model model) {
        String username = (String) session.getAttribute("username");
        String role = (String) session.getAttribute("role");

        model.addAttribute("username", username);
        model.addAttribute("role", role);

        System.out.println("username = " + username +
                " & role" + role + " > connects this serviceB Page!!!");
        return "serviceB";
    }

    @GetMapping("/serviceAdmin")
    public String serviceAdminPage(HttpSession session, Model model) {
        String username = (String) session.getAttribute("username");
        String role = (String) session.getAttribute("role");

        model.addAttribute("username", username);
        model.addAttribute("role", role);

        System.out.println("username = " + username +
                " & role" + role + " > connects this serviceAdmin Page!!!");
        return "serviceAdmin";
    }

}
  • /hello → 메인 페이지라고 보면 됨
  • /register → 회원가입
  • /login → 로그인
  • /serviceA → 로그인 이후, 모든 유저 이용 가능
  • /serviceB → 로그인 이후, 모든 유저 이용 가능
  • /serviceAdmin → 로그인 이후, 일반 유저 사용 불가 && 관리자만 사용 가능

serviceA.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>serviceA</title>
</head>
<body>
<h1>serviceA Page!!!</h1>
<div>
    <p th:text="'환영합니다, ' + ${username} + '님! role = ' + ${role}"></p>
    <a href="/logout">로그아웃</a>
    <a href="/serviceB">Service B</a>
    <a href="/serviceAdmin">Service Admin</a>
</div>
</body>
</html>

serviceB.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>serviceB</title>
</head>
<body>
<h1>serviceB Page!!!</h1>
<div>
    <p th:text="'환영합니다, ' + ${username} + '님! role = ' + ${role}"></p>
    <a href="/logout">로그아웃</a>
    <a href="/serviceA">Service A</a>
    <a href="/serviceAdmin">Service Admin</a>
</div>
</body>
</html>

serviceAdmin.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>serviceAdmin</title>
</head>
<body>
<h1>serviceAdmin Page!!!</h1>
<div>
    <h1>Admin이 아니시면, 해당 서비스를 이용하기 어려우십니다...</h1>
    <p th:text="'환영합니다, ' + ${username} + '님! role = ' + ${role}"></p>
</div>
<div th:if="${role} == admin">
    <a href="/serviceA">Service A</a>
    <a href="/serviceB">Service B</a>
    <a href="/serviceAdmin">Service Admin</a>
</div>
<div>
    <a href="/logout">로그아웃</a>
</div>
</body>
</html>
  • serviceA.html 과 serviceB.html 달리 serviceAdmin.html 에서는 role의 값이 admin 이 아니면, 인삿말을 제외한 것을 못 보게 했다.

화면 캡처

→ 로그인 전 /hello

→ /register

  • 회원가입 진행

  • DB에도 회원가입 시도한 user의 정보가 잘 들어온 것을 알 수 있다.

  • 이미 회원가입 되어 있던 User의 username과 동일한 경우, 중복 됐다고 알려준다.

→ /login

  • 로그인 화면

→ 로그인 상태인 /hello

  • username과, role이 보이고, 로그인 상태가 아닌 /hello와 달리, 로그아웃 & A & B & Admin 화면이 보인다는 것을 알 수 있다.

→ /serviceA

→ /serviceB

→ /serviceAdmin (role이 user인 경우)

  • serviceA와 serviceB와 달리, pcy 계정의 role이 ‘user’이기 때문에 serviceAdmin 이용이 어렵다고 한다. 그러면, admin role인 계정으로 접속을 해보면 어떨까?

→ /serviceAdmin (role이 admin인 경우)
업로드중..

  • user role이었던 pcy와 달리, admin role인 pcyAdmin의 화면 구성이 다르다는 것을 알 수 있다.
  • 이는, session에 저장되어 있던 사용자의 role의 값이 admin이기 때문에 가능하다.

이렇게, 세션을 활용한 인증 처리를 통해서 회원가입 및 로그인과, 로그인 된 user의 role에 따라서 접근할 수 있는 권한을 상이하게 할 수 있다는 것을 알 수 있다.

문제점

  1. 세션으로 인한 서버 메모리 관리
  • Spring의 세션 처리에서 언급했듯이 세션으로 처리할 경우, 서버 메모리에 과부하를 줄 수 있다.
  • 그렇기 때문에, 이런 것은 서버 메모리에 저장하지 않고 Redis 같은 것을 활용한다.
  1. password 평문 저장
  • 회원가입 이후, DB 사진에서 볼 수 있듯이 password가 평문으로 저장되어 있다. 이는 보안적으로 매우 큰 이슈에 해당한다.
  • 보통 Spring에서는 이 문제를 해결하고자 Spring Security를 활용하며, SHA-256을 활용해 단방향 암호화 기법을 통해 비밀번호를 암호화한다.

느낀점

CS 공부를 할 때, 쿠키와 세션에 대한 공부를 했었는데 실제로 활용하지는 못하고 암기로만 했었다.

하지만, 이번 기회를 통해 쿠키와 세션의 차이가 더 명확히 이해가 갔고, 더 나아가 Session을 활용해 user의 role에 따른 서비스 차등을 두어보며 재밌게 공부한 것 같다.

하지만, Session의 문제도 분명히 있고, 보안적으로 password를 평문으로 저장하는 것이 매우 위험하다는 것을 인지하고 있다.

다음으로는 password를 Spring Security를 활용해 평문이 아닌 암호화하여 저장해보고자 한다.

profile
#Software Engineer #IRISH

0개의 댓글