Spring Security 테스트

설명도 귀찮다. 바로 시작

책을 구매해서 내용들 따라하려 했지만 책에는 SpringSecurity의 버전이

Java 11을 기준으로 작성된 것인데다가 SpringSecurity3 이후로는 작성법이 변경된 관계로 방법을 찾느라 시간이 좀 걸렸다.

물론 해당 프로젝트는 java 17을 기준으로 하고 있다.

스프링 부트 프로젝트 설정은 인터넷에 널려있으니 찾아서 보면 된다.

벡엔드 구현

프로젝트 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
com.example.demo
|
├── /config/
│   ├── WebMvcConfig.java
│   └── WebSecurityConfig.java
├── /controller/
│   ├── TodoController.java
│   └── UserController.java
├── /DTO/
│   ├── ResponseDTO.java
│   ├── TodoDTO.java
│   └── UserDTO.java
├── /Model/
│   ├── TodoEntity.java
│   └── UserEntity.java
├── /Persistence/
│   ├── TodoRepository.java
│   └── UserRepository.java
├── /Security/
│   ├── JwtAuthenticationFilter.java
│   └── TokenProvider.java
├── /Service/
│   ├── TodoService.java
│   └── UserService.java
└─ DemoApplication.java

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
plugins {
	id 'java'
	id 'org.springframework.boot' version '3.2.6-SNAPSHOT'
	id 'io.spring.dependency-management' version '1.1.4'
}

apply plugin: "eclipse"

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
	maven { url 'https://repo.spring.io/snapshot' }
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	implementation group: 'com.google.guava', name: 'guava', version: '33.2.0-jre'
	
	implementation 'io.jsonwebtoken:jjwt:0.9.1'
	
	// javax.xml.bind
	implementation 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359'
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

	//ModelMapper
	implementation group: 'org.modelmapper', name: 'modelmapper', version: '2.4.2'
}

tasks.named('test') {
	useJUnitPlatform()
}


보통 데이터베이스 테이블마다 그에 상응하는 엔티티 클래스가 존재한다.

해당 프로젝트는 Todo List를 구현하는 것을 목표로 한다.

그리고 모델과 엔티티를 한 클래스에 구현한다. 복잡한 어플리케이션이 아니라서

모델(Model) : 주로 데이터베이스 조작과 관련된 로직을 처리하는 부분

엔티티(Entity) : 데이터베이스의 테이블과 매핑되는 클래스

이러한 용어들은 다양한 프레임워크나 라이브러리에서 다르게 사용될 수 있으므로, 문맥에 주의하여 사용해야 합니다.

TodoEntity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.demo.model;

import org.hibernate.annotations.GenericGenerator;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
@Entity
@Table(name = "Todo")
public class TodoEntity {
	@Id
	@GeneratedValue(generator = "system-uuid")
	@GenericGenerator(name="system-uuid", strategy = "uuid")
	private String id;		// 이 오브젝트의 아이디
	private String userId;	// 이 오브젝트를 생성한 유저의 아이디
	private String title;	// Todo 타이틀 예) 운동 하기
	private boolean done;	// true - Todo를 완료한 경우
}

서비스가 요청을 처리하고 클라이언트로 반환할 때, 모델 자체를 리턴하는 경우는 별로 없다. 일반적은로는 DTO를 사용하는데, 우선은 비즈니스 로직을 캡슐화하기 위함이다.

모델은 테이터베이스 테이블구조와 매우 유사하다. 모델이 가진 필드와 테이블의 스키마와 비슷할 확률이 높다. 대부분의 회사들은 외부인 자사의 데이터베이스의 스키마를 아는 것을 원치 않을 것이다. 그리고 클라이언트가 필요한 정보를 모두 포함하지 않는 경우가 있기 때문이다. 예를 들어 서비스 실행도중 에러가 나면 에러 메시지는 어디에 포함해야 할 까?

모델은 서비스 로직과는 관련이 없기 때문에 모델에 담기는 애매해서 DTO를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.example.demo.dto;

import com.example.demo.model.TodoEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@NoArgsConstructor
@AllArgsConstructor
@Data
public class TodoDTO {
	private String id;
	private String title;
	private boolean done;
	
	public TodoDTO(final TodoEntity entity) {
		this.id = entity.getId();
		this.title = entity.getTitle();
		this.done = entity.isDone();		
	}
	
	public static TodoEntity toEntity(final TodoDTO dto) {
		return TodoEntity.builder()
				.id(dto.getId())
				.title(dto.getTitle())
				.done(dto.isDone())
				.build();
	}
}

이제 http 응답으로 사용할 dto가 필요하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.demo.dto;

import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class ResponseDTO<T> {
	private String error;
	private List<T> data;
}

다음은 ResponseEntity를 리턴해주는 Controller다.

Todo Application: TodoController 설명

이 포스트에서는 Spring Boot로 작성된 Todo Application의 TodoController 클래스에 대해 설명합니다. 이 컨트롤러는 기본적인 CRUD (Create, Read, Update, Delete) 작업을 수행하며, TodoService를 이용해 데이터베이스와 상호작용합니다.

패키지 및 임포트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.demo.controller;


import com.example.demo.dto.ResponseDTO;
import com.example.demo.dto.TodoDTO;
import com.example.demo.model.TodoEntity;
import com.example.demo.service.TodoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

TodoControllerRestController로, HTTP 요청을 처리하는 여러 메서드를 포함하고 있습니다. 각 메서드는 Todo 리스트의 항목을 생성, 읽기, 업데이트, 삭제하는 기능을 담당합니다.

클래스 정의 및 서비스 자동 주입

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("todo")
public class TodoController {

  @Autowired
  private TodoService service;

  @GetMapping("/test")
  public ResponseEntity<?> testTodo() {
    String str = service.testService(); // 테스트 서비스 사용
    List<String> list = new ArrayList<>();
    list.add(str);
    ResponseDTO<String> response = ResponseDTO.<String>builder().data(list).build();
    return ResponseEntity.ok().body(response);
  }

/test 엔드포인트는 서비스의 테스트 메서드를 호출하고 결과를 반환합니다. 이 메서드는 서비스가 제대로 작동하는지 확인하는 데 사용됩니다.

Todo 리스트 조회

1
2
3
4
5
6
7
8
9
10
  @GetMapping
  public ResponseEntity<?> retrieveTodoList() {
    String temporaryUserId = "temporary-user"; // temporary user id.
    List<TodoEntity> entities = service.retrieve(temporaryUserId);	    // (1) 서비스 메서드의 retrieve메서드를 사용해 Todo리스트를 가져온다
    List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());	// (2) 자바 스트림을 이용해 리턴된 엔티티 리스트를 TodoDTO리스트로 변환한다.
    ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();	// (6) 변환된 TodoDTO리스트를 이용해ResponseDTO를 초기화한다.

    return ResponseEntity.ok().body(response);	// ResponseDTO를 리턴한다.
  }

Todo 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

  @PostMapping
  public ResponseEntity<?> createTodo(@AuthenticationPrincipal String userId, @RequestBody TodoDTO dto) {
    try {
      String temporaryUserId = "temporary-user"; // temporary user id.
      TodoEntity entity = TodoDTO.toEntity(dto); // TodoEntity로 변환한다.
      entity.setId(null);	// id를 null로 초기화 한다. 생성 당시에는 id가 없어야 하기 때문이다.
      // 임시 유저 아이디를 설정 해 준다. 이 부분은 4장 인증과 인가에서 수정 할 예정이다. 지금은 인증과 인가 기능이 없으므로 한 유저(temporary-user)만 로그인 없이 사용 가능한 애플리케이션인 셈이다
      entity.setUserId(temporaryUserId);
      List<TodoEntity> entities = service.create(entity);		// 서비스를 이용해 Todo엔티티를 생성한다.
      List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());	   // 자바 스트림을 이용해 리턴된 엔티티 리스트를 TodoDTO리스트로 변환한다.
      ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();	// 변환된 TodoDTO리스트를 이용해ResponseDTO를 초기화한다.

      return ResponseEntity.ok().body(response);	// ResponseDTO를 리턴한다.
    } catch (Exception e) {// 혹시 예외가 나는 경우 dto대신 error에 메시지를 넣어 리턴한다.
      String error = e.getMessage();
      ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().error(error).build();
      return ResponseEntity.badRequest().body(response);
    }
  }

	```

	#### Todo 업데이트

	```

  @PutMapping
  public ResponseEntity<?> updateTodo(@AuthenticationPrincipal String userId, @RequestBody TodoDTO dto) {
    String temporaryUserId = "temporary-user"; // temporary user id.
    TodoEntity entity = TodoDTO.toEntity(dto);	// dto를 entity로 변환한다.
    entity.setUserId(temporaryUserId);	// id를 temporaryUserId로 초기화 한다. 여기는 4장 인증과 인가에서 수정 할 예정이다.
    List<TodoEntity> entities = service.update(entity);	// 서비스를 이용해 entity를 업데이트 한다.
    List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());	// 자바 스트림을 이용해 리턴된 엔티티 리스트를 TodoDTO리스트로 변환한다.
    ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();	// 변환된 TodoDTO리스트를 이용해ResponseDTO를 초기화한다.
    return ResponseEntity.ok().body(response);	// ResponseDTO를 리턴한다.
  }

Todo 삭제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

  @DeleteMapping
  public ResponseEntity<?> deleteTodo(@AuthenticationPrincipal String userId, @RequestBody TodoDTO dto) {
    try {
      String temporaryUserId = "temporary-user"; // temporary user id.
      TodoEntity entity = TodoDTO.toEntity(dto);	// TodoEntity로 변환한다.
      // 임시 유저 아이디를 설정 해 준다. 이 부분은 4장 인증과 인가에서 수정 할 예정이다. 지금은 인증과 인가 기능이 없으므로 한 유저(temporary-user)만 로그인 없이 사용 가능한 애플리케이션인 셈이다
      entity.setUserId(temporaryUserId);
      List<TodoEntity> entities = service.delete(entity);	// 서비스를 이용해 entity를 삭제 한다.
      List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());	// 자바 스트림을 이용해 리턴된 엔티티 리스트를 TodoDTO리스트로 변환한다.
      ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();	// 변환된 TodoDTO리스트를 이용해ResponseDTO를 초기화한다.
      return ResponseEntity.ok().body(response);	// ResponseDTO를 리턴한다.
    } catch (Exception e) { // 혹시 예외가 나는 경우 dto대신 error에 메시지를 넣어 리턴한다.
      String error = e.getMessage();
      ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().error(error).build();
      return ResponseEntity.badRequest().body(response);
    }
  }
}

TodoService 설명

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
package com.example.demo.service;

import java.util.List;
import java.util.Optional;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import lombok.extern.slf4j.Slf4j;
import com.example.demo.model.TodoEntity;
import com.example.demo.persistence.TodoRepository;

@Slf4j
@Service
public class TodoService {
	
	@Autowired
	private TodoRepository repository;
	
	public String testService() {
		// todoEntity 생성
		TodoEntity entity = TodoEntity.builder().title("My first todo item").build();
		// todoEntity 저장
		repository.save(entity);
		// TodoEntity 검색
		TodoEntity savedEntity = repository.findById(entity.getId()).get();
		return savedEntity.getTitle();
	}
	
	// CREATE Method & Refactoring
	public List<TodoEntity> create(final TodoEntity entity) {
		// Validation
		validate(entity);
		
		repository.save(entity);
		
		log.info("Entity Id : {} is saved.", entity.getId());
		
		return repository.findByUserId(entity.getUserId());
	}
	
	private void validate(final TodoEntity entity) {
		if (entity == null) {
			log.warn("Entity cannot be null.");
			throw new RuntimeException("Entity cannot be null.");			
		}
		
		if (entity.getUserId() == null) {
			log.warn("Unknown user.");
			throw new RuntimeException("Unknown user.");			
		}
	}
	
	public List<TodoEntity> retrieve(final String userId) {
		return repository.findByUserId(userId);
	}
	
	public List<TodoEntity> update(final TodoEntity entity) {
	    validate(entity);		// 저장할 엔티티가 유효한지 확인
	    
	    // (2) 넘겨받은 엔티티 id를 이용해 TodoEntity를 가져온다. 존재하지 않는 엔티티는 업데이트 할 수 없기 때문이다.
	    final Optional<TodoEntity> original = repository.findById(entity.getId());	

	    original.ifPresent(todo -> {
	    // (3) 반환된 TodoEntity가 존재하면 값을 새 entity 의 값으로 덮어 씌운다.	
	      todo.setTitle(entity.getTitle());
	      todo.setDone(entity.isDone());
	      // (4) 데이터베이스에 새 값을 저장한다.

	      repository.save(todo);
	    });
	    
	    // Retrieve Todo 에서 만든 메서드를 이용해 유저의 모든 Todo 리스트를 리턴한다.
	    return retrieve(entity.getUserId());
	}
	
	public List<TodoEntity> delete(final TodoEntity entity) {
	    // (1) 저장 할 엔티티가 유효한지 확인한다. 이 메서드는 2.3.1 Create Todo 에서 구현했다.
	    validate(entity);
	
	    try {
	      // (2) 엔티티를 삭제한다.
	      repository.delete(entity);
	    } catch(Exception e) {
	      // (3) exception 발생시 id 와 exception 을 로깅한다.
	      log.error("error deleting entity ", entity.getId(), e);
	
	      // (4) 컨트롤러로 exception 을 날린다. 데이터베이스 내부 로직을 캡슐화 하기 위해 e를 리턴하지 않고 새 exception 오브젝트를 리턴한다.
	      throw new RuntimeException("error deleting entity " + entity.getId());
	    }
	    // (5) 새 Todo 리스트를 가져와 리턴한다.
	    return retrieve(entity.getUserId());
  }
	
}

TodoRepository 설명

영속성을 관리하기 위한 패키지를 만들어 넣었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.example.demo.persistence;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.example.demo.model.TodoEntity;

@Repository
public interface TodoRepository extends JpaRepository<TodoEntity, String> {
	List<TodoEntity> findByUserId(String usserId);
}

프론트엔드 구현

해당 내용은 node.js, vscode 환경에서 실행되었다.

Todo 리스트, Todo 삭제, Todo 수정

Todo.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import React, { useState } from "react";
import { ListItem, ListItemText, InputBase, Checkbox, ListItemSecondaryAction, IconButton } from "@mui/material";
import DeleteOutlined from "@mui/icons-material/DeleteOutlined";

const Todo = (props) => {
  const [item, setItem] = useState(props.item);
  const [readOnly, setReadOnly] = useState(true);
  const deleteItem = props.deleteItem;
  const editItem = props.editItem;

  const editEventHandler = (e) => {
    setItem({ ...item, title: e.target.value });
  };

  const checkboxEventHandler = (e) => {
    item.done = e.target.checked;
    editItem(item);
  }

  // deleteEventHandler 작성
  const deleteEventHandler = () => {
    deleteItem(item);
  };

  const turnOffReadOnly = () => {
    setReadOnly(false);
  }

  // turnOnReadOnly 함수 작성
  const turnOnReadOnly = (e) => {
    if (e.key === "Enter" && readOnly === false) {
      setReadOnly(true);
      editItem(item);
    }
  };

  return (
    <ListItem>
      <Checkbox checked={item.done}
        onChange={checkboxEventHandler} />
      <ListItemText>
        <InputBase
          inputProps=aria-label
          onClick={turnOffReadOnly}
          onKeyDown={turnOnReadOnly}
          onChange={editEventHandler}
          type="text"
          id={item.id}
          name={item.id}
          value={item.title}
          multiline={true}
          fullWidth={true}
        />
      </ListItemText>
      <ListItemSecondaryAction>
        <IconButton aria-label="Delete Todo"
          onClick={deleteEventHandler} >
          <DeleteOutlined />
        </IconButton>
      </ListItemSecondaryAction>
    </ListItem>
  );
};

export default Todo;

Todo 추가

AddTodo.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import React, { useState } from "react";

import { Button, Grid, TextField } from "@mui/material";

const AddTodo = (props) => {
  // 사용자의 입력을 저장할 오브젝트
  const [item, setItem] = useState({ title: "" });
  const addItem = props.addItem;

  // AddItem 핸들러 추가
  // onButtonClick 함수 작성
  const onButtonClick = () => {
    addItem(item); // addItem 함수 사용
    setItem({ title: "" });
  }

  // enterKeyEventHandler 함수
  const enterKeyEventHandler = (e) => {
    if (e.key === 'Enter') {
      onButtonClick();
    }
  };

  // onInputChange 함수 작성
  const onInputChange = (e) => {
    setItem({ title: e.target.value });
    console.log(item);
  };



  return (
    <Grid container style=>
      <Grid xs={11} md={11} item style=>
        <TextField placeholder="Add Todo here"
          fullWidth
          onChange={onInputChange}
          onKeyPress={enterKeyEventHandler}
          value={item.title} />
      </Grid>
      <Grid xs={1} md={1} item>
        <Button fullWidth style=
          color="secondary" variant="outlined" onClick={onButtonClick}>
          +
        </Button>
      </Grid>
    </Grid>
  );
}

export default AddTodo;

화면 구성

App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import './App.css';
import Todo from './Todo';
import React, { useState, useEffect } from "react";
import { Container, List, Paper, Grid, Button, AppBar, Toolbar, Typography } from "@mui/material"
import AddTodo from './AddTodo';
import { call, signout } from './service/ApiService';

function App() {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    call("/todo", "GET", null).then((response) => {
      setItems(response.data);
      setLoading(true);
    });
  }, []);

  const addItem = (item) => {
    call("/todo", "POST", item)
      .then((response) => setItems(response.data));
  };

  const editItem = (item) => {
    call("/todo", "PUT", item)
      .then((response) => setItems(response.data));
  };

  const deleteItem = (item) => {
    call("/todo", "DELETE", item)
      .then((response) => setItems(response.data));
  };

  let todoItems = items.length > 0 && (
    <Paper style=>
      <List>
        {items.map((item) => (
          <Todo
            item={item}
            key={item.id}
            editItem={editItem}
            deleteItem={deleteItem} />
        ))}
      </List>
    </Paper>
  );

  // navigationBar 추가
  let navigationBar = (
    <AppBar position='static'>
      <Toolbar>
        <Grid justifyContent="space-between" container>
          <Grid item>
            <Typography variant="h6">오늘의 할 일</Typography>
          </Grid>
          <Grid item>
            <Button color="inherit" raised onClick={signout}>
              로그아웃
            </Button>
          </Grid>
        </Grid>
      </Toolbar>
    </AppBar>
  );

  /* 로딩중이 아닐 때 렌더링 할 부분 */
  let todoListPage = (
    <div>
      {navigationBar} {/* 네비게이션 바 렌더링 */}
      <Container maxWidth="md">
        <AddTodo addItem={addItem} />
        <div className="TodoList">{todoItems}</div>
      </Container>
    </div>
  );

  /* 로딩중일 때 렌더링 할 부분 */
  let loadingPage = <h1> 로딩중.. </h1>;
  let content = loadingPage;

  if (!loading) {
    /* 로딩중이 아니면 todoListPage를 선택*/
    content = todoListPage;
  }

  /* 선택한 content 렌더링 */
  return <div className="App">{content}</div>;
};

export default App;

서비스 통합

! CORS(Cross-Origin Resource Sharing)

WenMvcConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.demo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration // 스프링 빈으로 등록
public class WebMvcConfig implements WebMvcConfigurer {

  private final long MAX_AGE_SECS = 3600;

  @Override
  public void addCorsMappings(CorsRegistry registry) {
    // 모든 경로에 대하여
    registry.addMapping("/**")
        // Origin이 http:localhost:3000에 대해.
        .allowedOrigins("http://localhost:3000")
        // GET, POST, PUT, PATCH, DELETE, OPTIONS 메서드를 허용한다.
        .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
        .allowedHeaders("*")
        .allowCredentials(true)
        .maxAge(MAX_AGE_SECS);
  }

}

ApiService.js

백엔드로 요청을 보낼 때 사용하기 위한 유틸리티 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import { API_BASE_URL } from "../api-config";

export function call(api, method, request) {
  let headers = new Headers({
    "Content-Type": "application/json",
  });

  // 로컬 스토리지에서 ACCESS TOKEN 가져오기
  const accessToken = localStorage.getItem("ACCESS_TOKEN");
  if (accessToken && accessToken !== null) {
    headers.append("Authorization", "Bearer " + accessToken);
  }

  let options = {
    headers: headers,
    url: API_BASE_URL + api,
    method: method,
  };

  if (request) {
    // GET method
    options.body = JSON.stringify(request);
  }
  return fetch(options.url, options).then((response) => {
    if (response.status === 200) {
      return response.json();
    } else if (response.status === 403) {
      window.location.href = "/login";  // redirect
    } else {
      new Error(response);
    }
  }).catch((error) => {
    console.log("http error");
    console.log(error);
  });
}

export function signin(userDTO) {
  return call("/auth/signin", "POST", userDTO)
    .then((response) => {
      if (response.token) {
        // 로컬 스토리지에 토큰 저장
        localStorage.setItem("ACCESS_TOKEN", response.token);
        // token이 존재하는 경우 todo 화면으로 리다이렉트
        window.location.href = "/";
      }
    });
}

export function signout() {
  localStorage.setItem("ACCESS_TOKEN", null);
  window.location.href = "/login";
}

export function signup(userDTO) {
  return call("/auth/signup", "POST", userDTO);
}

App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import './App.css';
import Todo from './Todo';
import React, { useState, useEffect } from "react";
import { Container, List, Paper, Grid, Button, AppBar, Toolbar, Typography } from "@mui/material"
import AddTodo from './AddTodo';
import { call, signout } from './service/ApiService';

function App() {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    call("/todo", "GET", null).then((response) => {
      setItems(response.data);
      setLoading(true);
    });
  }, []);

  const addItem = (item) => {
    call("/todo", "POST", item)
      .then((response) => setItems(response.data));
  };

  const editItem = (item) => {
    call("/todo", "PUT", item)
      .then((response) => setItems(response.data));
  };

  const deleteItem = (item) => {
    call("/todo", "DELETE", item)
      .then((response) => setItems(response.data));
  };

...

REST API 인증 기법

Basic 인증

사용자ID와 비밀번호를 base64로 인코딩한 뒤, 헤더에 담아 서버에게 보내는 인증 방식

basic

  1. 클라이언트가 요청을 보낸다.
  2. 서버가 401(Unauthorized) 응답과 함께 WWW-Authenticate 헤더에 클라이언트가 어떻게 인증을 해야할 지를 담아 보낸다.
  3. 클라이언트는 WWW-Authenticate에 담긴 인증 방식대로 Authorization헤더에 인증 정보를 담아 다시 요청을 보낸다.
  4. 서버는 클라이언트의 인증 정보를 보고 200(OK) or 403(Forbidden) 응답을 한다.

단점

📌 추가적인 보안 필요 HTTP Request를 보면 누구나 유저의 ID & 패스워드를 알 수 있다. 그렇기에 HTTP보다 보안이 강화된 HTTPS와 함께 쓰이는 것이 일반적이다. (HTTPS는 패킷이 탈취되는 것을 방지한다. MITM)

📌 서버 성능 저하 또한, Basic인증의 경우 서버가 요청을 받을 때마다 클라이언트가 보낸 유저ID&비밀번호와 일치하는 유저를 DB에서 매번 찾아야한다. 그렇기에 DB에 저장된 유저가 많을 수록, 트래픽이 많을 수록 서버 성능이 저하된다.

📌 정교한 권한 설정이 어려움 Basic인증 방법으로 정교하게 사용자 권한을 제어하려면 추가적인 작업이 필요하다고 한다. 솔직히 안해봐서 잘 모르겠다.

Bearer 인증

토큰은 그냥 문자열이다. 예를 들자면 abcde 이것도 토큰이 될 수 있다. 하지만 실제로 사용되는 토큰은 외울 수 없도록 만들어져 있다. 명시적으로는 Authorization: Bearer <TOKEN>으로 사용한다.

basic

토큰은 최초 로그인 시 서버가 UUID로 토큰을 작성해 넘긴하다고 하자. 그러면 서버는 이 토큰을 위의 그림 처럼 토큰을 생성해 인증 서버를 통해 저장해야 한다. 그리고 요청을 받을 떄마다 헤더의 토큰을 서버의 토큰과 비교해 클라이언트를 입증할 수 있다.

우선 Basic 토큰과는 달리 비밀번호를 매번 네트워크를 통해 전송해야 할 필요가 없다 -> 보안 측면에서 좀 더 안전. 또 토큰은 서버가 마음대로 생성할 수 있으므로 사용자의 인가 정보또는 유효기간을 정해 관리할 수 있다. 또 디바이스 마다 다르개 생성해 줄 수도 있다.

하지만 아직도 스케일 문제가 남아있다. (앞서 말한 Basic의 문제와 동일)

JWT(JOSN Web Token)

서버에 의해 전자 서명된 토큰을 이용하면 인증으로 인한 스케일 문제 해결 가능

JWT 토큰의 구성 : {header}.{payload}.{signature}

jwt

HEADER

일반적으로 토큰의 타입과 토큰이 어떤 암호 방식을 사용했는지를 담고있다.

PAYLOAD

유저 네임등 인증에 필요한 정보들을 담고있다.

SIGNATURE

서버의 secret key를 사용해 서명한 내용을 담고있다. 서버가 서명한 정보이므로, 토큰의 값이 중간에 변경되는 것을 검증할 수 있다. Encoded 된 HEADER, PAYLOAD와 secret key를 암호화한 정보를 담고있다. 암호화 알고리즘은 HEADER에 적힌 방법대로 수행한다.

jwt

  • 클라이언트가 로그인 요청을 보낸다. 유저정보를 바탕으로 {header}.{payload} 작성 생성된 {header}.{payload}를 secret키로 전자 서명 -> 결과 : X, {header}.{payload}.X를 Base64로 인코딩 후 반환

  • 서버는 인증 과정을 거친 뒤, 토큰을 생성한다.
  • 서버는 토큰을 응답에 담아 보낸다.
  • 클라이언트는 토큰을 브라우저에 저장한다.(localStorage나 쿠키 등)
  • 클라이언트는 이후 요청을 보낼 때 마다 요청 헤더에 토큰을 담아 보낸다. 유정게서 받은 <Token>을 Base64로 디코딩 -> 결과 : {header}.{payload}.X 앞부분 {header}.{payload}를 떼서 secret키로 전자 서명 -> 결과 : Y 디코딩된 토큰의 마지막 부분 X와 방금 전자 서명한 결과 Y를 비교 X = Y 인 경우 서명이 일치하므로 검증 완료
  • 서버는 받은 토큰을 해석해 유저를 검증한다.

User 레이어 구현

사용자를 관리하기 위해서는 유저에 관련된 모델, 서비스, 리포지터리, 컨트롤러가 필요하다.

UserEntity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.example.demo.model;


import org.hibernate.annotations.GenericGenerator;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = "username")})
public class UserEntity {
	@Id
	@GeneratedValue(generator="system-uuid")
    @GenericGenerator(name="system-uuid", strategy = "uuid")
	private String id;
	
	@Column(nullable = false)
	private String username;
	private String password;
	private String role;
	private String authProvider;
}

UserRepository.java

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.demo.persistence;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.example.demo.model.UserEntity;

@Repository
public interface UserRepository extends JpaRepository<UserEntity, String> {
	
	UserEntity findByUsername(String username);
	Boolean existsByUsername(String username);
	UserEntity findByUsernameAndPassword(String username, String password);
}

UserService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.example.demo.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import com.example.demo.model.UserEntity;
import com.example.demo.persistence.UserRepository;

import lombok.extern.slf4j.Slf4j;


@Slf4j
@Service
public class UserService {
	
	@Autowired
	private UserRepository userRepository;
	
	public UserEntity create(final UserEntity userEntity) {
		if(userEntity == null || userEntity.getUsername() == null) {
			throw new RuntimeException("Invalid arguments");
		}
		final String username = userEntity.getUsername();
		if(userRepository.existsByUsername(username)) {
			log.warn("Username already exists {}", username);
			throw new RuntimeException("Username already exists");
		}
		
		return userRepository.save(userEntity);
	}
	
	public UserEntity getByCredentials(final String username, final String password, final PasswordEncoder encoder) {
		final UserEntity originalUser = userRepository.findByUsername(username);
		
		// matches 메서드를 이용해 패스워드가 같은지 확인
		if (originalUser != null && 
				encoder.matches(password, originalUser.getPassword())) {
			return originalUser;
		}
		return null;
	}
}

UserDTO.java

UserController 구현하기 전에 현재 유저를 가져오는 기능 구현에 필요한 부분

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.demo.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
	private String token;
	private String username;
	private String password;
	private String id;
}

UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
package com.example.demo.controller;

import com.example.demo.dto.ResponseDTO;
import com.example.demo.dto.UserDTO;
import com.example.demo.model.UserEntity;
import com.example.demo.security.TokenProvider;
import com.example.demo.service.UserService;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/auth")
public class UserController {

  @Autowired
  private UserService userService;
  
  @Autowired
  private TokenProvider tokenProvider;
  
  private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();


  @PostMapping("/signup")
  public ResponseEntity<?> registerUser(@RequestBody UserDTO userDTO) {
    try {
      if(userDTO == null || userDTO.getPassword() == null ) {
    	  throw new RuntimeException("Invalid Password value.");
      }
      // 요청을 이용해 저장할 유저 만들기
      UserEntity user = UserEntity.builder()
          .username(userDTO.getUsername())
          .password(userDTO.getPassword())
          .build();
      // 서비스를 이용해 리포지터리 에 유저 저장
      UserEntity registeredUser = userService.create(user);
      UserDTO responseUserDTO = UserDTO.builder()
          .id(registeredUser.getId().toString())
          .username(registeredUser.getUsername())
          .build();

      return ResponseEntity.ok(responseUserDTO);
    } catch (Exception e) {
      // 유저 정보는 항상 하나이므로 리스트로 만들어야 하는 ResponseDTO를 사용하지 않고 그냥 UserDTO 리턴.

      ResponseDTO responseDTO = ResponseDTO.builder().error(e.getMessage()).build();
      return ResponseEntity.badRequest().body(responseDTO);
    }
  }


  @PostMapping("/signin")
  public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO) {
    UserEntity user = userService.getByCredentials(
        userDTO.getUsername(),
        userDTO.getPassword(),
        passwordEncoder);

    if(user != null) {
      // 토큰 생성
      final String token = tokenProvider.createToken(user.getId().toString());	
      final UserDTO responseUserDTO = UserDTO.builder()
          .username(user.getUsername())
          .id(user.getId())
          .token(token)
          .build();
      return ResponseEntity.ok(responseUserDTO);
    } else {
      ResponseDTO responseDTO = ResponseDTO.builder()
          .error("Login failed.")
          .build();
      return ResponseEntity
          .badRequest()
          .body(responseDTO);
    }
  }


}

해당 사진은 PostMan에서 실행한 결과이다.

basic

basic

결과는 잘 나오지만 문제가 있다. 딱 로그인만 되고 로그인 상태가 유지되지 않는다. 그리고 로그인 여부 자체를 확인하지 않는다. 마지막으로 비밀번호를 암호화 하지 않는다. (JWT는 반드시 https와 함께 사용해야한다. 나중에 설명)

Spring Security 통합

JWT 인증 로직 구현

TokenProvider.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package com.example.demo.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.Date;

@Service
public class TokenProvider {
	
	  @Value("${jwt.secret}")
	  private String jwtSecret;
	  
	  @Value("${jwt.expiration}")
	  private int jwtExpirationInMs;
	  
	  private static final String SECRET_KEY = "FlRpX30pMqDbiAkmlfArbrmVkDD4RqISskGZmBFax5oGVxzXXWUzTR5JyskiHMIV9M1Oicegkpi46AdvrcX1E6CmTUBc6IFbTPiD";
	
	  public String createToken(String userId) {
	    // 기한 지금으로부터 1일로 설정
		Date now = new Date();
		Date expiryDate = new Date(now.getTime() + jwtExpirationInMs);
	
	  /*
	  { // header
	    "alg":"HS512"
	  }.
	  { // payload
	    "sub":"40288093784915d201784916a40c0001",
	    "iss": "demo app",
	    "iat":1595733657,
	    "exp":1596597657
	  }.
	  // SECRET_KEY를 이용해 서명한 부분
	  Nn4d1MOVLZg79sfFACTIpCPKqWmpZMZQsbNrXdJJNWkRv50_l7bPLQPwhMobT4vBOG6Q3JYjhDrKFlBSaUxZOg
	   */
	    // JWT Token 생성
	    return Jwts.builder()
	        // header 에 들어갈 내용 및 서명을 하기 위한 SECRET_KEY
			.setSubject(userId) // sub
	        .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
	        .setIssuer("demo app") 			// iss
	        .setIssuedAt(new Date()) 		// iat
	        .setExpiration(expiryDate) 		// exp
	        .compact();
	  }

	  public String validateAndGetUserId(String token) {
	    // parseClaimsJws메서드가 Base 64로 디코딩 및 파싱.
	    // 즉, 헤더와 페이로드를 setSigningKey로 넘어온 시크릿을 이용 해 서명 후, token의 서명 과 비교.
	    // 위조되지 않았다면 페이로드(Claims) 리턴, 위조라면 예외를 날림
	    // 그 중 우리는 userId가 필요하므로 getBody를 부른다.
	    Claims claims = Jwts.parser()
	        .setSigningKey(SECRET_KEY)
	        .parseClaimsJws(token)
	        .getBody();
	
	    return claims.getSubject();
	  }
	  
	  public boolean validateToken(String authToken) {
		  try {
			  Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(authToken);
			  return true;
		  } catch (Exception e) {
			return false;
		  }
	  }
}

UserController의 /signin에서 토큰 생성 및 반환

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package com.example.demo.controller;

import com.example.demo.dto.ResponseDTO;
import com.example.demo.dto.UserDTO;
import com.example.demo.model.UserEntity;
import com.example.demo.security.TokenProvider;
import com.example.demo.service.UserService;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/auth")
public class UserController {

  @Autowired
  private UserService userService;
  
  @Autowired
  private TokenProvider tokenProvider;
  
  private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();


  @PostMapping("/signup")
  public ResponseEntity<?> registerUser(@RequestBody UserDTO userDTO) {
    try {
      if(userDTO == null || userDTO.getPassword() == null ) {
    	  throw new RuntimeException("Invalid Password value.");
      }
      // 요청을 이용해 저장할 유저 만들기
      UserEntity user = UserEntity.builder()
          .username(userDTO.getUsername())
          .password(passwordEncoder.encode(userDTO.getPassword()))
          .build();
      // 서비스를 이용해 리포지터리 에 유저 저장
      UserEntity registeredUser = userService.create(user);
      UserDTO responseUserDTO = UserDTO.builder()
          .id(registeredUser.getId())
          .username(registeredUser.getUsername())
          .build();

      return ResponseEntity.ok(responseUserDTO);
    } catch (Exception e) {
      // 유저 정보는 항상 하나이므로 리스트로 만들어야 하는 ResponseDTO를 사용하지 않고 그냥 UserDTO 리턴.

      ResponseDTO responseDTO = ResponseDTO.builder().error(e.getMessage()).build();
      return ResponseEntity.badRequest().body(responseDTO);
    }
  }


  @PostMapping("/signin")
  public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO) {
    UserEntity user = userService.getByCredentials(
        userDTO.getUsername(),
        userDTO.getPassword(),
        passwordEncoder);

    if(user != null) {
      // 토큰 생성
      final String token = tokenProvider.createToken(user.getId().toString());	
      final UserDTO responseUserDTO = UserDTO.builder()
          .username(user.getUsername())
          .id(user.getId())
          .token(token)
          .build();
      return ResponseEntity.ok(responseUserDTO);
    } else {
      ResponseDTO responseDTO = ResponseDTO.builder()
          .error("Login failed.")
          .build();
      return ResponseEntity
          .badRequest()
          .body(responseDTO);
    }
  }
}

basic basic

  • Base64로 디코딩한 토큰
    1
    2
    3
    4
    5
    6
    7
    8
    {"alg":"HS512"}
    {
      "sub":"40285be790f462040190f46235620000",
      "iss":"demo app",
      "iat":1722086999,
      "exp":1722173399
    }
    y{	F8^C]\'D_5D&Ӛ3@-)Ccf7>_O:>	// 의미없는 값
    

스프링 시큐리티와 서블릿 필터

basic

위의 그림처럼 API가 실행될 때마다 사용자를 인증해주는 부분을 구현해야 한다. 이 부분은 스프링 시큐리티의 도움을 받아 구현한다.

스프링 시큐리티는 간단히 말하면 서블릿 필터의 집합이다. 서블릿 필터는 서블릿 실행 전에 실행되는 클래스들이다. 스프링이 구현하는 서블릿은 바로 디스패처 서블릿이다. 서블릿 필터는 디스패처 서블릿이 실행되기전에 항상 실행된다.

서블릿 필터는 구현된 로직에 따라 원치 않는 http 요청을 걸러 낼 수 있다. 필터를 거쳐 살아남은 요청만 디스패쳐 서블릿으로 넘어와 우리 컨트롤러에서 실행된다.

인증 완료시 다음 서블릿 필터를 실행, 아니라면 HttpServletResponse의 status를 403 Forbidden으로 바꾼다. 예외의 경우 디스패쳐 서블릿을 실행하지 않고 리턴될 것이다.

스프링 부트를 사용하지 않는 다면 web.xml과 같은 설정 파일에 이 필터를 어느 경로(예, /todo)에 적용해야 하는지 알려줘야 한다.

-> 개발자인 우리는 서블릿 필터를 구현하고 서블릿 컨테이너가 실행하도록 설정만 해주면 끝!

JWT를 이용한 인증 구현

JwtAuthenticationFilter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package com.example.demo.security;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            String token = parseBearerToken(request);
            log.info("JwtAuthenticationFilter is running...");
            log.info("Token: " + token);
            if (token != null && !token.equalsIgnoreCase("null")) {
                String userId = tokenProvider.validateAndGetUserId(token);
                log.info("Authenticated user ID: " + userId);
                AbstractAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userId, null, AuthorityUtils.NO_AUTHORITIES);
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
                securityContext.setAuthentication(authentication);
                SecurityContextHolder.setContext(securityContext);
            }
        } catch (Exception ex) {
            logger.error("Could not set user authentication in security context", ex);
        }

        filterChain.doFilter(request, response);
    }

    private String parseBearerToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        log.info("Bearer Token: " + bearerToken);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

스프링 시큐리티 설정

WebSecurityConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.example.demo.config;

import com.example.demo.security.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.filter.CorsFilter;


@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

  @Autowired
  private final JwtAuthenticationFilter jwtAuthenticationFilter;
 
  
  public WebSecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
      this.jwtAuthenticationFilter = jwtAuthenticationFilter;
  }
  
  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    // http 시큐리티 빌더
    http.cors(Customizer.withDefaults()) // WebMvcConfig에서 이미 설정했으므로 기본 cors 설정.
    	.csrf(csrf -> csrf.disable()) // stateless한 rest api를 개발할 것이므로 csrf 공격에 대한 옵션은 꺼둔다.
    	.httpBasic(httpBasic -> httpBasic.disable()) // token을 사용하므로 basic 인증 disable
    	.sessionManagement((session) -> session	
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))	// session 기반이 아님을 선언
    	.authorizeHttpRequests(authorizeRequests -> 
    		authorizeRequests.requestMatchers("/", "/auth/**").permitAll() 	// /와 /auth/** 경로는 인증 안해도 됨.	// /와 /auth/**이외의 모든 경로는 인증 해야됨.
    		.anyRequest().authenticated());
    
	http.addFilterAfter(jwtAuthenticationFilter, CorsFilter.class);
    
    // filter 등록.
    // 매 요청마다
    // CorsFilter 실행한 후에
    // jwtAuthenticationFilter 실행한다.
    
    return http.build();
  }
}
  • 스프링 시큐리티 필터 로그

    2024-07-27T22:29:44.406+09:00 INFO 23460 --- [demo] [main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [ org.springframework.security.web.session.DisableEncodeUrlFilter@60317de8, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@363e2009, org.springframework.security.web.context.SecurityContextHolderFilter@31885b4b, org.springframework.security.web.header.HeaderWriterFilter@7a57c5d9, org.springframework.web.filter.CorsFilter@7b55fc83, com.example.demo.security.JwtAuthenticationFilter@609319c3, org.springframework.security.web.authentication.logout.LogoutFilter@106ac5f4, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@43c64d6f, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@6b247ef6, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@3615f8d9, org.springframework.security.web.session.SessionManagementFilter@4f93a8f6, org.springframework.security.web.access.ExceptionTranslationFilter@3bdc8975, org.springframework.security.web.access.intercept.AuthorizationFilter@15af06f]

basic basic

패스워드 암호화 로직 구현

UserService.java 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

import org.springframework.security.crypto.password.PasswordEncoder;

/* 기존 코드 생략 */


@Slf4j
@Service
public class UserService {
	
	/* 기존 코드 생략 */
	
	public UserEntity getByCredentials(final String username, final String password, final PasswordEncoder encoder) {
		final UserEntity originalUser = userRepository.findByUsername(username);
		
		// matches 메서드를 이용해 패스워드가 같은지 확인
		if (originalUser != null && 
				encoder.matches(password, originalUser.getPassword())) {
			return originalUser;
		}
		return null;
	}
}

UserController.java 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package com.example.demo.controller;

import com.example.demo.dto.ResponseDTO;
import com.example.demo.dto.UserDTO;
import com.example.demo.model.UserEntity;
import com.example.demo.security.TokenProvider;
import com.example.demo.service.UserService;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/auth")
public class UserController {

  @Autowired
  private UserService userService;
  
  @Autowired
  private TokenProvider tokenProvider;
  
  private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();


  @PostMapping("/signup")
  public ResponseEntity<?> registerUser(@RequestBody UserDTO userDTO) {
    try {
      if(userDTO == null || userDTO.getPassword() == null ) {
    	  throw new RuntimeException("Invalid Password value.");
      }
      // 요청을 이용해 저장할 유저 만들기
      UserEntity user = UserEntity.builder()
          .username(userDTO.getUsername())
          .password(passwordEncoder.encode(userDTO.getPassword()))
          .build();
      // 서비스를 이용해 리포지터리 에 유저 저장
      UserEntity registeredUser = userService.create(user);
      UserDTO responseUserDTO = UserDTO.builder()
          .id(registeredUser.getId())
          .username(registeredUser.getUsername())
          .build();

      return ResponseEntity.ok(responseUserDTO);
    } catch (Exception e) {
      // 유저 정보는 항상 하나이므로 리스트로 만들어야 하는 ResponseDTO를 사용하지 않고 그냥 UserDTO 리턴.

      ResponseDTO responseDTO = ResponseDTO.builder().error(e.getMessage()).build();
      return ResponseEntity.badRequest().body(responseDTO);
    }
  }


  @PostMapping("/signin")
  public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO) {
    UserEntity user = userService.getByCredentials(
        userDTO.getUsername(),
        userDTO.getPassword(),
        passwordEncoder);

    if(user != null) {
      // 토큰 생성
      final String token = tokenProvider.createToken(user.getId().toString());	
      final UserDTO responseUserDTO = UserDTO.builder()
          .username(user.getUsername())
          .id(user.getId())
          .token(token)
          .build();
      return ResponseEntity.ok(responseUserDTO);
    } else {
      ResponseDTO responseDTO = ResponseDTO.builder()
          .error("Login failed.")
          .build();
      return ResponseEntity
          .badRequest()
          .body(responseDTO);
    }
  }
}