스프링 시큐리티로 인증&인가 구현하기
유효한 계정인지?
해당 요청에 대한 권한이 있는지?
💡 클라이언트 요청 시 해당 유저에게 권한이 있는지 확인하기
1. 로그인한 유저는 해당 게시글을 조회할 수 있는 권한이 있는가?
2. 먼저, 로그인을 한 유저가 맞는가?
3. 로그인(인증) 해당 게시글의 조회 권한(인가)을 구현해보자.
관리자 계정과 일반 유저 계정으로 나눈다. [USERS
, ADMIN
]
일반 유저 계정은 등급이 나뉜다. [BRONZE
, SILVER
, GOLD
]
🎯 요청 테스트를 위한 컨트롤러
테스트를 위해 총 3개의 Api 가 있으며 시나리오는 아래와 같다.
/security/all
: 모든 유저가 접근할 수 있다. (다만, 로그인을 해야함.)
/security/admin
: 관리 계정만 접근할 수 있다. (ADMIN
)
/security/gold
: 골드 등급의 계정만 접근할 수 있다. (GOLD
)
@RestController
@RequestMapping("/security")
public class SecurityApiController {
@GetMapping("/all")
public Map<String, String> getInformation() {
return Map.of(
"data1", "모두가 볼 수 있는 정보1"
, "data2", "모두가 볼 수 있는 정보2"
, "data3", "모두가 볼 수 있는 정보3"
);
}
@GetMapping("/admin")
public Map<String, String> getInformationOnlyAdmin() {
return Map.of(
"data1", "ADMIN만 볼 수 있는 정보1"
, "data2", "ADMIN만 볼 수 있는 정보2"
, "data3", "ADMIN만 볼 수 있는 정보3"
);
}
@GetMapping("/gold")
public Map<String, String> getDTOOnlyGold() {
return Map.of(
"data1", "GOLD 만 볼 수 있는 정보1"
, "data2", "GOLD 만 볼 수 있는 정보2"
, "data3", "GOLD 만 볼 수 있는 정보3"
);
}
}
PostMan
테스트 결과현재 모든 요청이 접근 가능하다.
🎯 Security dependencies
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.14'
id 'io.spring.dependency-management' version '1.1.3'
}
...
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
의존성 추가 이후 애플리케이션 실행 시 모든 엔드포인트의 호출은 막히게 된다.
이는 개발자를 대신해 스프링 시큐리티가 즉시 사용 가능한 OOTB(out-of-the-box 즉시 사용 가능한)
기능을 제공하기 때문에 엔드포인트 접근 시 401 Unauthorized
응답을 받는다.
스프링 시큐리티는 아무런 설정 없이 사용할 때 모든 수준에서 최대의 보안이 기본값
을 채택한다.
이는 개발자의 별다른 작업 없이도 프로젝트에 스프링 시큐리티가 포함되어 있다면 애플리케이션에 보안 목표가 있음을 뜻한다.
스프링 부트+시큐리티 자동 설정은 상당한 수의 필수적인 빈을 생성한다.
(사용자 ID 와 비밀번호를 이용하는 사용자 인가와 폼 인증을 기반으로 한 기본 보안 기능을 구현하기 위해서)
브라우저로 접속하게 되면 기본 로그인 화면으로 이동하게 되는데,
스프링 부트 애플리케이션의 로깅에Using generated security password: '비밀번호'
의'비밀번호'
를 복사한 후
패스워드를 입력해 접속해보자.
자동 생성되는 기본 계정의 Id는user
다
로그인 후 요청시 모든 요청은 성공한다.
패스워드는 애플리케이션이 시작될 때마다 새로 생성된다.
시큐리티 의존성 추가만으로 최대의 보안 효과를 얻었지만,
자동 생성되는 단 하나의 유저와 비밀번호를 모든 이용자가 공유할 수는 없다.
단 하나의 계정을 모든 이용자가 공유한다면
책임과 인증의 보안 원칙이 위배되며
이용하는 유저를 고유하게 증명할 수 없다.
설정을 통해 회원가입한 유저들의 요청만 인증&인가를 처리한다.
🎯 UserDetailsService
스프링 시큐리티 인증 기능의 핵심이다.
단일 메서드가 존재하는 인터페이스이며 해당 메서드를 통해 사용자의 정보들을 얻는다.
package org.springframework.security.core.userdetails;
public interface UserDetailsService {
/**
* Locates the user based on the username. In the actual implementation, the search
* may possibly be case sensitive, or case insensitive depending on how the
* implementation instance is configured. In this case, the <code>UserDetails</code>
* object that comes back may have a username that is of a different case than what
* was actually requested..
* @param username the username identifying the user whose data is required.
* @return a fully populated user record (never <code>null</code>)
* @throws UsernameNotFoundException if the user could not be found or the user has no
* GrantedAuthority
*/
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
UserDetails
를 반환하기만 하면 되기 때문에 기본 구현의 세부 정보를 알 필요가 없다.
🎯 UserDetails
User Entity
를 구현체로 설정해보자.
@Getter
@NoArgsConstructor(access = AccessLevel.PACKAGE)
@Entity
public class Users implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@JsonProperty(access = READ_WRITE)
@Column(nullable = false, unique = true)
private String email;
@JsonProperty(access = WRITE_ONLY)
@Column(nullable = false)
private String password;
@JsonProperty(access = WRITE_ONLY)
@Column(nullable = false)
private String role;
@JsonProperty(access = WRITE_ONLY)
@Column(nullable = false)
private String grade;
@Builder
public Users(Long id, String email, String password, String role, String grade) {
this.id = id;
this.email = email;
this.password = password;
this.role = role;
this.grade = grade;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(this.role), new SimpleGrantedAuthority(this.grade));
}
@JsonProperty(access = WRITE_ONLY)
@Override
public String getUsername() {
return this.email;
}
@JsonProperty(access = WRITE_ONLY)
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonProperty(access = WRITE_ONLY)
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonProperty(access = WRITE_ONLY)
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@JsonProperty(access = WRITE_ONLY)
@Override
public boolean isEnabled() {
return true;
}
}
기본 엔티티에 role
과 grade
는 권한이다.
role
: USERS
or ADMIN
grade
: BRONZE
or SILVER
or GOLD
오라이딩된 메서드들은 다음과 같다.
getAuthorities()
: 계정이 가지고 있는 권한 목록 리턴getUsername()
: 계정의 이름을 리턴. (식별자)isAccountNonExpired()
: 계정이 만료됐는지? (true는 만료되지 않음)isAccountNonLocked()
: 계정이 잠겼는지? (true는 잠기지 않음)isCredentialsNonExpired()
: 비밀번호가 만료됐는지? (true는 만료되지 않음)isEnabled()
: 계정이 활성화됐는지? (true는 활성화 상태)🎯 이용자들
시나리오를 짚고 넘어간다.
new InMemoryUserDetailsManager('이용자1', '이용자2'...);
에 저장한다.총 두명의 이용자가 있다.
Users lkdcode = Users.builder()
.email("lkdcode@email.com")
.password(passwordEncoder.encode("password123"))
.grade("GOLD")
.role("ADMIN")
.build();
Users another = Users.builder()
.email("another@email.com")
.password(passwordEncoder.encode("password123"))
.grade("SILVER")
.role("USERS")
.build();
lkdcode
: ADMIN
과 GOLD
권한을 가지고 있다.another
: USERS
과 SILVER
권한을 가지고 있다.🎯 SpringSecurity 설정용 클래스
테스트를 위해 InMemoryUserDetailsManager(lkdcode, another);
에 저장한다.
//...
return new InMemoryUserDetailsManager(lkdcode, another);
//...
🎯 SpringSecurity 설정용 클래스
@Configuration
public class SecurityConfig {
private final PasswordEncoder passwordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder();
@Bean
public UserDetailsService authentication() {
Users lkdcode = Users.builder()
.email("lkdcode@email.com")
.password(passwordEncoder.encode("password123"))
.grade("GOLD")
.role("ADMIN")
.build();
Users another = Users.builder()
.email("another@email.com")
.password(passwordEncoder.encode("password123"))
.grade("SILVER")
.role("ADMIN")
.build();
return new InMemoryUserDetailsManager(lkdcode, another);
}
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests()
.antMatchers("/security/admin").hasAuthority("ADMIN")
.antMatchers("/security/gold").hasAuthority("GOLD")
// .requestMatchers("/security/admin").hasAuthority("ADMIN") Boot 3.0.2ver
// .requestMatchers("/security/gold").hasAuthority("GOLD") Boot 3.0.2ver
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.httpBasic()
.and()
.build();
}
}
.antMatchers("/security/admin").hasAuthority("ADMIN") // : 해당 요청은 `ADMIN` 권한만 접근 가능.
.antMatchers("/security/gold").hasAuthority("GOLD") // : 해당 요청은 `GOLD` 권한만 접근 가능.
.hasRole('권한');
: 해당 메서드는 접두사로 "ROLE_"
이 자동으로 추가된다.🎯 결과
포스트맨에 Basic Auth
를 추가한다.
유저 lkdcode
는 "ADMIN"
, "GOLD"
권한을 가지고 있다.
/security/all
요청 성공/security/admin
요청 성공/security/gold
요청 성공유저 another
는 "ADMIN"
, "SILVER"
권한을 가지고 있다.
/security/all
요청 성공/security/admin
요청 성공/security/gold
요청 실패 403 Forbidden
설정한 인증&인가대로 요청이 잘 처리 됐다.
해당 요청에 대한 로깅을 찍기 위해
application.yml
을 설정할 수 있다.
logging:
level:
org.springframework.security: DEBUG