SSR이란?
Server-Side Rendering(서버 사이드 렌더링)의 약자
-> 서버가 완성된 HTML 화면을 만들어서 브라우저에 보내주는 방식.
지금처럼 Spring Boot에서 Thymeleaf 또는 Mustache를 사용한다면 SSR 방식이고 React처럼 서버가 주로 JSON 데이터만 주고 브라우저가 화면을 만드는 방식은 보통 CSR(Client-Side Rendering)이라고 한다.

아무런 인가설정이 되어있지 않기 때문에 전역에 접근이 가능하다.

의존성과 SecurityConfig 설정으로 시큐리티 필터라는 베이스 클래스가 자동으로 추가된다. 여기에 username과 password 기반의 로그인 시스템과 인가 코드만 부여하면 쉽게 로그인 구현이 가능하다.
톰캣이란?
스프링부트 3을 공부할때부터 스프링부트와 코드가 톰캣과 함께 작동한다고 들었다. 톰캣이 정확히 무엇일까?
: 자바로 만든 웹 애플리케이션을 실행하고 클라이언트의 HTTP 요청을 처리하는 웹 서버이자 서블릿 컨테이너
Postman -> 톰캣(요청 주소, HTTP 메서드 등 확인) -> 스프링 부트에 요청 전달 -> DispatcherServlet -> Controller -> 톰캣(HTTP 응답)
서블릿(Servlet)
HTTP 요청을 받아서 응답을 만드는 자바 프로그램
Spring을 사용하면 알아서 다 만들어서 관리됀다.
DispatcherServlet은 스프링 MVC에서 모든 요청을 가장 먼저 받아서 적절한 컨트롤러로 보내는 역할 수행
MVC
MVC는 Model–View–Controller의 약자
웹 애플리케이션의 역할을 세 부분으로 나눠서 관리하는 구조
-> 위 부분들에 대해서는 추후 더 공부해보도록 하겠다.

데이터베이스 설정을 하지 않고 스프링만 연동한 채 실행을 누르면 에러가 뜨고 실행이 되지 않는다.
따라서 White Label Error Page를 보기 위해서일지라도 데이터베이스 연결은 필수적으로 해야한다.
DB 연결을 위해 application.properties에 코드를 추가한다.
# MySQL DB connection
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://아이피:306/논리디비이름?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
spring.datasource.username=root
spring.datasource.password=비밀번호
# JPA ddl, Naming and log
spring.jpa.hibernate.ddl-auto=update
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
spring.jpa.show-sql=true
]
@Controller
public class SampleController {
@GetMapping("/")
public String index(){
return "index";
}
@GetMapping("/user")
public String user() {
return "user";
}
@GetMapping("admin")
public String admin(){
return "admin";
}
}
@GetMapping 어노테이션을 활용하여 사용자가 보낸 각 경로의 GET 요청을 각각의 컨트롤러 메서드와 연결한다.
@Controller vs @RestController
@Controller는 화면을 반환할때 주로 사용한다. 위에서 사용한 "index"의 값은 index 문자열을 가리키는 것이 아닌 index html 화면을 가리키는 것이다.
@RestController는 데이터값을 바로 반환할 때 사용한다. 위 상황에서 @RestController를 썼다면 실행했을때 스프링이 객체를 JSON으로 변환해서 HTTP 응답 본문에 넣었기 때문에 "index"라는 문자열을 그대로 반환했을 것이다.
이 프로젝트에서는 일반 @Controller를 사용하였기 때문에 각 entity에 따른 html을 생성해주었다.
@RequestMapping("/")와 @GetMapping("/")
("") 속에 /는 URL 경로를 의미한다.
@GetMapping은 GET 요청을 받을 URL 경로이고
@RequestMapping은 클래스의 공통 URL 경로를 지정하여 비슷한 기능끼리 묶을 수 있게 한다.
이 클래스 내부에 시큐리티 코드를 통제할 @Bean들을 등록한다.
@Bean은 개발자가 만든 객체를 스프링이 관리하도록 등록하는 어노테이션

시큐리티 필터는 여러개의 필터로 이루어진다. 보안 검증, 로그아웃, 로그인, 세션관리, 인가 등등..
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
DB가 유출되었을 경우 비밀번호를 알 수 없도록 법으로 지정되어있다.
따라서 암호화를 할 수 있는 PassordEncoder를 등록하여 BCrypt를 사용한다. (일반적으로 많이 사용하는 암호화 방법)
로그인 시 유저가 로그인 요청시 입력한 username/password와 DB에 저장되어있는 username/password를 비교해야 한다.
이를 위해서는 미리 DB에 유저 정보를 저장해야 한다.
@Entity
@Getter
@Setter
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String username;
private String password;
@Enumerated(EnumType.STRING)
private UserRole role;
}
public enum UserRole {
ADMIN,
USER
}
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder){
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
// 회원가입 메소드
public void join(UserRequestDTO dto){
String username = dto.getUsername();
String password = dto.getPassword();
UserEntity entity = new UserEntity();
entity.setUsername(username);
entity.setPassword(passwordEncoder.encode(password));
entity.setRole(UserRole.USER);
userRepository.save(entity);
}
}
이미 동일한 유저네임이 있는지 체크하는 기능 또는 성공하면 리턴값을 주는 기능 등으로 디테일과 에러에 신경쓸 수 있다.
@Setter
@Getter
public class UserRequestDTO {
private String username;
private String password;
}
@Controller
public class JoinController {
private final UserService userService;
public JoinController(UserService userService){
this.userService = userService;
}
// 회원 가입 데이터 처리 메소드
@PostMapping
public String join(UserRequestDTO dto){
userService.join(dto);
return "redirect:/";
}
// 회원 가입 페이지 제공 메소드
@GetMapping("/join")
public String joinPage(){
return "join";
}
}
"redirect:"의 경우 현재 인텔리제이에서 html까지 만들어 보내므로 사용한 것이고 프론트와 협업할 때는 프론트가 그 부분을 맡아주기 때문에 크게 신경쓰지 않아도 된다.



password 같은 경우 암호화가 잘 되어서 저장된 것을 확인할 수 있다.

파란색이 앞쪽 / 빨간색이 뒤쪽
로그인 페이지 -> 아이디/비밀번호 -> 시큐리티 필터 -> AuthenticationManager -> AuthenticationProvider'
어센티케이션이라고.. 읽는다.
SecurityConfig 클래스 및 스프링 시큐리티 의존성 기본 활성화 값을 활용하여 위 구성을 다 직접 구현하지 않고 일부만을 설정하겠다.
AuthenticationManager vs AuthenticationProvider
AuthenticationManager - 인증 요청을 받아서 누가(Contoroller) 실제로 인증을 처리할지 정하는 역할
AuthenticationProvider - 실제 인증을 수행, DB에서 사용자를 찾고 PasswordEncoder로 비밀번호가 맞는지 검사한다.
@Controller
public class LoginController {
@GetMapping
public String loginPage(){
return "longin";
}
}
그냥 @Controller 사용하였으니 login html 페이지를 만들어 반환할 수 있도록 한다.
// 로그인 필터 설정
http
.formLogin(login -> login
.loginProcessingUrl("/login")
.loginPage("login"));
이 설정을 통해 여러가지의 시큐리티 필터 중 로그인 요청을 만나면 로그인 필터를 거칠 수 있게 된다.
DB -> Repository -> Service -> AuthenticationProvider -> AuthenticationManager
직접 DB 조회 부분을 구현 후 Service 단에서 규칙을 맞춰주면 AuthenticationProvider가 데이터를 불러서 사용한다.
@Service
public class UserService implements UserDetailsService {
UserDetailsServie라는 클래스를 만들겠다는 의미인데 아래에 사용이 안되고 있으므로 이것만 작성하면 오류가 나타난다.
따라서 상단 오류를 누르고 메소드 구현을 클릭하면
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return null;
}
자동으로 이런식의 구현을 해준다.
이 코드가 곧 AuthenticationProvider를 불러서 쓸 메소드이다.
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity entity = userRepository.findByUsername(username).orElseThrow();
return User.builder()
.username(entity.getUsername())
.password(entity.getPassword())
.roles(entity.getRole().name())
.build();
}
자동생성된 코드 안에 Repository에서 username 기반으로 정보를 얻어 username과 암호화된 password, role을 User에게 반환해준다.
이때의 User는 내가 생성한 UserEntity가 아닌 Form 로그인 사용시 자동으로 생성되는 엔티티이다.
@Override⭐
: 부모 클래스나 인터페이스에 이미 정의된 메서드를 자식 클래스에서 다시 구현했다는 표시
부모 클래스를 상속받은 자식 클래스는 부모 클래스를 가져와서 쓸 수 있다. 또한 overload를 사용하면 객체에 대해 자신의 클래스에서 재정의할 수 있으므로 변형한 형태로도 쓸 수 있다.
세션 방식이기 때문에 로그인에 성공하면 서버가 유저를 기억하게 된다. 따라서 매 접속시 해당 유저인지 알 수 있는 것이다. 따라서 유저를 추가적으로 기억하는 메커니즘은 구현하지 않아도 된다.
SampleController
@GetMapping("/")
public String index(){
String username= SecurityContextHolder.getContext().getAuthentication().getName();
String role = SecurityContextHolder.getContext().getAuthentication().getAuthorities().toString();
System.out.println(username);
System.out.println(role);
return "index";
}
경로별 접근하는 유저에 대한 접근 허용과 거부 설정
인가 단위
필터 쪽 인가 -> role 별
컨트롤러쪽 인가: User에서 username 별
// 인가 필터에 대한 설정
http
.authorizeHttpRequests(auth ->auth
.requestMatchers("/").permitAll()
.requestMatchers("/join").permitAll()
.requestMatchers("/login").permitAll()
.requestMatchers("/user").hasRole("USER","ADMIN")
.requestMatchers("/admin").hasAnyRole("ADMIN")
.anyRequest().denyAll()
);
메인 페이지 -> 모든 접근 허용
회원가입 -> 모든 접근 허용
로그인 -> 모든 접근 허용
유저 페이지 -> user 권한을 가진 사람만 허용
admin 페이지 -> admin 권한을 가진 사람만 허용
이외의 경로 -> 모두 거부
기타: 메소드별로 나누는 방법
.requestMatchers(Http.GET,"/").permitAll();메인 페이지로 오는 GET 요청만 모두 허용하겠다.
public boolean authCheck(Long postId) {
String username = SecurityContextHolder.getContext()
.getAuthentication()
.getName();
// 커스텀 쿼리
return postRepository.existsIdAndUsernameCustom(postId, username);
}
username별로 자신의 글만 삭제나 수정할 수 있다던지 하는 권한 검사 메소드 등록
CSRF가 정확히 뭘까?
공격자가 사용자의 비밀번호를 알아내는 것이 아니라 이미 로그인된 사용자의 브라우저, 즉 그 안에 저장된 쿠키를 이용해 요청을 대신 보내는 공격
정상 세션 로그인 방법
유저 로그인 -> 서버에서 세션 생성 -> 브라우저가 세션 쿠키를 받음 -> 서버에 접속하고 요청을 보낼때마다 쿠키를 "자동" 전달 -> 이것으로 사용자 판별
CSRF 공격이 가능한 이유
-> 예를 들어 사용자가 은행 사이트에 로그인한 상태라면
사용자의 브라우저에는 은행 로그인 세션 쿠키가 저장되어 있다.
이 상태에서 사용자가 공격자의 사이트에 접속할 경우
공격자의 사이트에는 사용자가 송금 버튼을 누르지 않았지만 은행 서버로 송금 요청을 보내는 코드가 써있을 수 있다.
이때 브라우저는 은행 사이트로 요청을 보내면서 은행의 세션 쿠키를 자동으로 포함할 수 있으며 이 때문에 은행은 이 요청이 사용자의 요청인줄 착각하게 된다.
이것을 막기 위한 방법으로 CSRF 토큰이라는 임의의 값을 사용한다.
사용자가 HTML 페이지를 응답할 때 서버는 사용자에게 CSRF 토큰을 함께 넣는다.
정상적인 사용자가 이 폼을 제출하면 쿠키와 CSRF 토큰 값이 함께 전송된다.
서버는 요청을 처리하기 전에 쿠키와 CSRF 토큰 값이 모두 올바른지 확인한다.
CSRF 토큰이 요청 위조를 막는 이유
세션 쿠키는 브라우저가 자동으로 전송하지만
CSRF 토큰은 정상 서버가 제공한 HTML 페이지 안에 포함된 별도의 값이다.
따라서 서버는 CSRF 토큰을 확인함으로써 해당 요청이 정상 서버에서 제공한 폼을 통해 만들어진 요청인지 검사할 수 있다.
CSRF 필터를 사용하게 되면 앞으로 서버의 상태를 바꾸는 요청은 모두 CSRF 토큰을 담아서 보내야 한다.
-> 몇가지 경로는 예외하도록 설정 가능
http
.csrf(csrf -> csrf
.ignoringRequestMatchers("/logout"));
/logout 요청에 대해서는 CSRF 토큰 검사를 하지 않도록 예외 처리
CSRF 설정시 로그아웃은 무조건 POST 요청(서버의 상태변경)만 허용되기 때문에 위 설정을 추가하기도 한다.
로그인 상태 유지를 위한 기능
일반 로그인은 세션이 살아있는 짧은 기간만 로그인이 유지되지만 리멤버미는 오랜 시간 후 접속해도 로그인 유지가 가능하다.
ex) 네이버의 로그인 유지기능
영속성이란?
프로그램이나 서버가 꺼져도 데이터가 사라지지 않고 계속 남아 있는 성질.
예시로 데이터 값을 변수에만 저장하게 되면 프로그램이 종료되는 순간 사라진다. 하지만 DB에 값을 저장해두면 서버가 꺼지더라도 데이터는 유지된다.
Remember me에서 말하는 영속성 사용 방식은 주로 자동 로그인용 토큰을 DB에 저장하여 사라지지 않게 함을 의미한다.
이번에는 쿠키 방식으로 구현한다.
Security Config
// remember me 설정
http
.rememberMe(me -> me
.key("vmfhaltmskdlsvmfhaltmskdlsvmfhaltmskdls")
.rememberMeParameter("remember-me")
.tokenValiditySeconds(14*24*60*60));
.tokenValiditySeconds(142460*60)은 Remember Me 쿠키의 유효기간을 14일로 설정하는 것을 의미한다.
14일 × 24시간 × 60분 × 60초로 계산
key 값 설정하는 방법
랜덤한 문자열로 작성해도 작동은 하지만 환경변수로 아래처럼 관리하는 것이 가장 좋다.
- .env에 키를 저장
- application.yml에서 환경변수 받기
- Security Config에서 값 주입받아 사용
.env는 .gitignore에 적어놓는 파일로 DB 비밀번호, 보안 키처럼 환경마다 다르고 공개하면 안 되는 값을 저장할 때 사용한다.
일반 세션은 짧게 유지하더라도 세션이 만료되면 서버는 그 세션을 제거하고 나중에 사용자가 다시 사이트에 접속하면 브라우저의 Remember Me 쿠키를 검사해 자동 인증하고 새로운 세션을 생성하는 방식으로 자동 로그인 된다.
HTTP VS HTTPS
HTTP와 HTTPS는 비슷하지만 HTTPS는 HTTP 통신에 암호화와 서버 인증을 추가한 방식이라고 생각하면 보다 쉬워진다.
그래서 중간에서 누가 통신을 보더라도 내용을 알아보기 어렵고 사용자가 접속한 서버가 진짜 해당 사이트인지도 확인할 수 있다.
(hierarchy = 하이어라키..)
스프링 시큐리티에서 role을 설계하다보면 몇 권한에 대해 계층이 있는 경우가 많다.
이 시스템의 경우에도 admin > user의 계층을 가진다.
이 role 계층 구조를 스프링 시큐리티한테 알려줌으로써 구현을 단순화할 수 있다.
// 기존 인가
.requestMatchers("/user").hasAnyRole("USER", "ADMIN")
// 계층 설정 후 인가
.requestMatchers("/user").hasAnyRole("USER")
admin의 권한이 user의 권한을 포함하고 있다는 것을 시큐리티에게 알려 role에 user만 써도 admin의 접근이 가능하도록 만들 수 있다.
@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.withRolePrefix("ROLE_")
.role("ADMIN").implies("USER")
.build();
}
@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("USER")
.build();
}
@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.fromHierarchy("""
ROLE_ADMIN > ROLE_USER
""");
}
각 형식의 차이점은 무엇일까?
여기서는 명시적으로 접두사를 작성하는 메소드 형식을 사용하여 권한 포함 정보를 설정했다.
implies를 사용해서 admin이 user의 권한을 포함하도록 한다.
실무 환경에서는 단순 ROLE 이외에도 여러 요소에 의해 인가가 결정되기도 한다.
SecurityConfig
// 인가 필터에 대한 설정(admin만 수정)
.requestMatchers("/admin").access(customAuthorizationManager())
SecurityConfig
private AuthorizationManager<RequestAuthorizationContext> customAuthorizationManager() {
return (authentication, context) -> {
boolean allowed =
authentication.get().getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
return new AuthorizationDecision(allowed);
};
}
/admin 경로에 대한 접근 권한을 hasrole로 하지 않고 커스텀 AuthorizationManager가 판단하도록 설정했다. 해당 매니저는 현재 사용자의 권한 목록을 확인하고 ROLE_ADMIN 권한이 존재하면 접근을 허용하고 그렇지 않으면 거부한다.
여기서는 hasrole 검사와 사실상 별반 다르지 않지만 학습을 위함이고 좀 더 큰 프로젝트에서 도입한다면 유용하게 사용할 수 있다.
: SecurityConfig에서 세션 관련 설정을 디폴트로 두면 세션이 state 상태로 설정되며 서버 메모리에 유저 정보가 저장된다.
stateful 상태에서는 서버가 유저를 기억하지만 stateless 상태에서는 로그인을 했지만 다음 HTTP 요청에서는 유저를 인지하지 못하기에 다음 요청마다 사용자가 자신의 인증 정보를 다시 보내야 한다.
// 세션
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
설정하지 않으면 state 상태
서버 세션 유지 기간 설정, 기본 값으로 1800초이고 유지 기간은 마지막 요청 시점으로부터 측정된다. 너무 길게 잡는다면 서버측 메모리에 세션 데이터가 쌓이기 때문에 무리가 갈 수 있다.
세션 state 방식은 유저를 기억한다.
이는 브라우저에게 JSESSIONID라는 쿠키를 발급하고 브라우저가 서버에 접속하면 JSESSIONID 키 값을 기반으로 서버 메모리에서 유저 정보를 찾는 방식으로 동작한다.
이것에 대해 세션 고정 공격이라는 웹 해킹 기법이 있다.

해커의 JSESSIONID 값을 일반 유저에게 심고 유저가 로그인을 진행하면 서버 메모리에는 해커의 JSESSIONID 값에 유저의 로그인 정보가 담기게 된다. 이후 해커가 해당 JSESSIONID 값으로 요청을 보내면 유저인척 할 수 있게 된다.
: 로그인이 성공되면 JSESSIONID 값을 변경하도록 설정, 공격자가 로그인 전 세션 ID를 알고 있더라도 로그인 후에는 세션 ID가 달라지므로 인증된 세션을 사용할 수 없다.
세션 ID는 매 요청마다 바뀌는 것이 아니라 로그인 성공 시 변경된다.
CSRF와 세션 고정 공격의 차이
CSRF는 피해자가 이미 정상적으로 로그인한 세션을 공격자가 몰래 이용하는 공격 -> 공격자가 세션 ID를 알 필요 없이, 피해자의 브라우저가 세션 쿠키를 자동으로 보내는 것을 이용
세션 고정 공격은 공격자의 세션 ID를 피해자에게 사용하게 만든 뒤, 피해자가 로그인하면 그 세션을 같이 사용하는 공격 -> 피해자가 로그인한 뒤 공격자가 같은 세션 ID를 사용해서 계정을 탈취하는 방식