Spring Security
- 막강한 인증, 인가 기능을 가진 프레임워크
OAuth
: 인터넷 사용자들이 비밀번호 제공하지 않고 다른 웹 사이트 상의 자신들의 정보에 대해 웹 사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단.
- ex: 네이버 아이디로 로그인, 구글 아이디로 로그인, ...
들어가기 전에...
본 프로젝트에서는 Spring Boot 2 방식인 Spring Security Oauth2 Client 라이브러리를 사용한다.
- 기존의 1.5에서 사용되던 프로젝트는 신규 기능이 더이상 없을 예정
- 스프링 부트용 라이브러리 출시
- 이전 사용 방식은 확장 포인트가 적절히 오픈되어 있지 않아 직접 상속/오버라이딩 해야함
이 책 외의 스프링 부트 2 방식의 자료를 찾고 싶은 경우,
- spring-security-oauth2-autoconfigure 라이브러리 사용 |
위 두 가지만 확인하면 된다.
구글 로그인 인증정보 발급
1. console.cloud.google.com/ 접속 및 로그인
2. 새 프로젝트 생성
프로젝트 이름에 freelec-springboot2-webservice를 입력해 생성한다.
3. 사용자 인증 정보 만들기
사용자 인증 정보 만들기 버튼을 클릭해 OAuth 클라이언트 ID를 생성한다.
4. 동의 화면 구성
User Type이나 앱 이름, 지원 이메일 등 정보를 입력한 뒤 생성한다.
필수가 아닌 것들은 굳이 입력할 필요 없다.
3으로 돌아가서 다시 클라이언트 ID를 누르면 다른 화면이 뜬다.
5. OAuth 클라이언트 ID 만들기
승인된 리디렉션 URI에는 http://localhost:8080/login/oauth2/code/google 를 입력하고 만들기 버튼을 누른다.
승인된 리디렉션 URI ▼
: 서비스에서 파라미터로 인증 정보를 주었을 때 인증 성공 시 구글에서 리다이렉트할 URL
- spring boot2 버전의 security에서는 기본적으로 {domain}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URL을 지원한다.
- 사용자가 별도로 리다이렉트 URL을 지원하는 Controller 생성할 필요 x
(시큐리티에서 이미 구현해 놓음)
- AWS 서버에 배포 시 localhost 외 추가로 주소를 추가해야 함.
6. application-oauth.properties 생성
application.properties와 같은 위치(src/main/resources)에 생성
spring.security.oauth2.client.registration.google.client-id=클라이언트ID
spring.security.oauth2.client.registration.google.client-secret=클라이언트PW
spring.security.oauth2.client.registration.google.scope=profile, email
id, pw는 위에서 받은 것으로 설정한다.
코드 설명 ▼
scope=profile, email
- 기본 값이 openid, profile, email이라서 별도 등록하지 않는 예제도 많다.
- 강제로 profile, email로 등록한 이유: openid라는 scope가 있을 경우 Open Id Provider로 인식
(OpenId Provider인 서비스(구글)와 그렇지 않은 서비스(카카오, 네이버, ...)로 나눠 OAuth2Service 만들어야 하는 상황이 오게 됨)
application-xxx.properties
- spring boot에서 위 형식의 파일을 생성할 경우, xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있음
- profile=xxx라는 식으로 호출하면 해당 properties의 설정 가져오기 가능
application.properties에 다음 코드 추가
spring.profiles.include=oauth
깃과 연동하는 사용자들의 경우, application-oauth.properties파일이 드러나는 것이 꺼려지는게 당연하다.
.gitignore에서 application-oauth.properties 를 추가한 뒤, 커밋했을 때 커밋 파일 목록에 해당 파일이 나타나지 않으면 성공.
구글 로그인 연동하기
1. 도메인 생성
com/spaws/book/springboot/domain/user/User.java
@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
@Column
private String picture;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Builder
public User(String name, String email, String picture, Role role) {
this.name = name;
this.email = email;
this.picture = picture;
this.role = role;
}
public User update(String name, String picture) {
this.name = name;
this.picture = picture;
return this;
}
public String getRoleKey() {
return this.role.getKey();
}
}
(Role 클래스 아직 생성되지 않은 상태라 붉은색 줄이 뜨는 것이 맞다)
코드 설명 ▼
@Enumerated(EnumType.STRING)
- JPA로 db 저장 시 Enum 값을 어떤 형태로 저장할지 결정
- 기본적으로는 int로 된 숫자가 저장
(숫자로 저장 시 db 확인 할때 무슨 코드를 의미하는지 알 수 x -> 문자로 저장하게 설정함)
Role.java 생성
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
Spring Security에서는 권한 코드에 항상 ROLE_xxx 형식이어야 한다.
User의 CRUD 책임을 질 UserRepository 생성한다.
public interface UserRepository extends JpaRepository<User,Long> {
Optional<User> findByEmail(String email);
}
2. Spring Security 설정
build.gradle 추가
compile('org.springframework.boot:spring-boot-starter-oauth2-client')
com/spaws/book/springboot/config/auth에 SecurityConfig.java 생성
앞으로 이 config.auth 패키지에 시큐리티 관련 클래스들을 모두 담을 예정이다.
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
private final CustomOAuth2UserService customOAuth2UserService;
@Override
protected void configure(HttpSecurity http) throws Exception{
http.csrf().disable()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/","/css/**","/images/**","/js/**","/h2-console/**","/profile").permitAll()
.antMatchers("/api/v1/**").hasRole(Role.USER.name())
.anyRequest().authenticated()
.and()
.logout().logoutSuccessUrl("/")
.and()
.oauth2Login()
.userInfoEndpoint()
.userService(customOAuth2UserService);
}
}
(CustomOAuth2UserService은 아직 만들지 않은 클래스라 빨간줄이 뜬다.)
코드 설명 ▼
@EnableWebSecurity
- Spring Security 설정들 활성화
.csrf().disable().headers().frameOptions().disable()
- h2 콘솔을 위한 설정들 disable
authorizeRequests
- URL별 권한 권리를 설정하는 옵션의 시작점
- authorizeRequests가 선언되어야만 antMatchers 옵션 사용 가능
antMatchers
- 권한 관리 대상 지정
- URL, HTTP 메소드별 관리 가능
- "/"등 지정된 URL은 permitAll() 옵션을 통해 전체 열람 권한
- "/api/v1/**" 주소를 가진 api는 USER 권한을 가진 사람만 가능
anyRequest
- 설정된 값들 이외 나머지 URL들
- 여기서는 .authenticated()를 추가해 나머지 url들은 모두 인증된 사용자들에게만 허용하게 함
(인증된 사용자=로그인한 사용자)
logout().logoutSuccessUrl("/")
- 로그아웃 성공 시 / 주소로 이동
oauth2Login
- OAuth 2 로그인 기능에 대한 설정 진입
userInfoEndpoint
- OAuth 2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들 담당
userService
- 로그인 성공 시 후속 조치를 진행할 UserService.interface의 구현체 등록
- 리소스 서버(소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시 가능
com/spaws/book/springboot/config/auth에 CustomOAuth2UserService.java
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final HttpSession httpSession;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
/* registrationId
* 현재 로그인 진행 중인 서비스 구분하는 코드.
* 이후에 여러가지 추가할 때 네이버인지 구글인지 구분
*/
String registrationId = userRequest.getClientRegistration().getRegistrationId();
/* userNameAttributeName
* OAuth2 로그인 진행 시 키가 되는 필드값 (=Primary Key)
* 구글 기본 코드: sub, 네이버 카카오 등은 기본 지원 x
* 이후 네이버, 구글 로그인 동시 지원시 사용
*/
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
/* OAuthAttributes
* OAuth2UserService를 통해 가져온 OAuth2User의 attribute
* 네이버 등 다른 소셜 로그인도 이 클래스 사용
*/
OAuthAttributes attributes = OAuthAttributes.
of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
User user = saveOrUpdate(attributes);
/* SessionUser
* 세션에 사용자 정보를 저장하기 위한 dto 클래스
* (User 클래스를 사용하지 않고 새로 만들었다.)
*/
httpSession.setAttribute("user", new SessionUser(user));
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey());
}
private User saveOrUpdate(OAuthAttributes attributes) {
User user = userRepository.findByEmail(attributes.getEmail())
.map(entity-> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
(OAuthAttOAuthAttributes와 SessionUser는 아직 생성하지 않아 import 되지 않는게 정상이다.)
com/spaws/book/springboot/config/auth/dto에 OAuthAttOAuthAttributes.java
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
@Builder
public OAuthAttributes(Map<String, Object> attributes,
String nameAttributeKey, String name,
String email, String picture) {
this.attributes = attributes;
this.nameAttributeKey = nameAttributeKey;
this.name = name;
this.email = email;
this.picture = picture;
}
/* of()
* OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나 변환
*/
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.picture((String) attributes.get("picture"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
/* toEntity()
* User 엔티티 생성
* OAuthAttributes에서 엔티티 생성 시점 = 처음 가입 시
* OAuthAttributes 클래스 생성이 끝났으면 같은 패키지에 SessionUser 클래스 생성
*/
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST) // 가입 기본 권한 == GUEST
.build();
}
}
com/spaws/book/springboot/config/auth/dto에 SessionUser.java
@Getter
public class SessionUser implements Serializable{
private String name;
private String email;
private String picture;
public SessionUser(User user) {
this.name = user.getName();
this.email = user.getEmail();
this.picture = user.getPicture();
}
}
SessionUser은 인증된 사용자 정보만 필요하다.
그 외 정보들은 필요가 없어 name, email, picture만 필드로 선언한다.
User 클래스를 두고 SessionUser 클래스를 따로 생성한 이유가 뭘까?
User 클래스를 사용한다면 Failed to convert from type [java.lang.Object] to type [byte[]] for value 'com.spaws.book.springboot.domain.user.User@4a43d6' 이런 에러가 뜰 것이다.
이를 해석하자면 User 클래스를 세션에 저장하려 했지만 User 클래스에 직렬화를 구현하지 않았다는 의미다.
User 클래스는 엔티티 클래스이기 때문에 언제 다른 엔티티와 관계가 형성될지 모른다.
@OneToMany, @ManyToMany 등 자식 엔티티를 갖고 있다면 그 자식까지 직렬화 대상에 포함된다.
성능 이슈, 부수 효과가 발생할 확률이 높아지므로 직렬화 기능을 가진 세션 Dto를 따로 생성하는 것이 운영 및 유지보수에 많은 도움이 된다.
3. 로그인 테스트
index.mustache에 로그인 버튼 및 로그인 성공 시 사용자 이름을 보여주는 코드를 추가했다.
...
<div class="col-md-12">
<!-- 로그인 기능 영역 -->
<div class="row">
<div class="col-md-6">
<a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
{{#userName}}
Logged in as: <span id="user">{{userName}}</span>
<a href="/logout" class="btn btn-info active" role="button">Logout</a>
{{/userName}}
{{^userName}}
<a href="/oauth2/authorization/google" class="btn btn-success acitve"
role="button">Google Login</a>
{{/userName}}
</div>
</div>
<br>
<!-- 목록 출력 영역 -->
...
코드 설명 ▼
{{#userName}}
- Mustache는 if문을 제공하지 않고 T/F 여부만 판단한다.
-> 항상 최종 값을 넘겨줘야 한다.
- userName이 있다면 userName을 노출
a href="/logout"
- spring security 에서 기본적으로 제공하는 로그아웃 URL
(별도로 이 URL에 해당하는 컨트롤러 만들 필요 X)
- SecurityConfig 클래스에서 URL 변경 가능\
{{^userName}}
- Mustache에 해당 값이 존재하지 않는 경우 ^ 사용
- userName이 없다면 로그인 버튼 노출
a href="/oauth2/authorization/google"
- spring security에서 기본적으로 제공하는 로그인 URL
(별도로 이 URL에 해당하는 컨트롤러 만들 필요 X)
IndexController에 코드 수정
...
private final PostsService postsService;
private final HttpSession httpSession;
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
// 로그인 성공 시 httpSession.getAttribute("user") 에서 값 가져올 수 있음
SessionUser user = (SessionUser) httpSession.getAttribute("user");
if(user!=null) { // session에 저장된 값이 있을 때만 model에 userName으로 등록
model.addAttribute("userName", user.getName());
}
return "index";
}
...
이제 localhost:8080을 들어가서 로그인, 로그아웃이 되는지 테스트해보면 된다.
+ 테스트 오류
오류1 WhiteLabel
-> 파일을 찾지 못했을 수 있다.
-> CustomOAuth2UserService에서 delegate 변수의 타입이 OAuth2UserService<OAuth2UserRequest, OAuth2User>가 아닌 OAuth2UserService 이어야 한다.
오류2 localhost:8080 접속 시 구글 화면으로 감
- 패키지 위치 확인
- config 파일 확인 (config파일에 일부 빠뜨린 코드가 있었어서 오류가 났다.)
오류3 이름이 계정 이름이 아닌 OS 사용자 이름으로 뜨는 경우
- IndexController의 addmodel.addAttribute("userName", user.getName()); 부분에서 "userName"을 "userNames"로 바꿨다. (index.mustache도 따라서 userName -> userNames로)
userName이라는 이름을 스프링 부트나.. 다른 곳에서 사용하고 있기 때문에 충돌난 것이 아닌가 싶다. db에는 변경 없이도 문제 없이 저장된다.
오류4 게시글 등록 불가능한 현상
- 회원가입 시 권한이 GUEST라서 그렇다. db에 들어가서 role을 USER로 수정한 뒤 작성하면 된다.
어노테이션 기반으로 개선하기
앞서 만든 코드에선 개선점이 하나 있다.
IndexController에서 세션 값을 가져오는 부분을 보자
SessionUser user = (SessionUser) httpSession.getAttribute("user");
index 메소드 외에 다른 컨트롤러와 메소드에서 세션 값이 필요하다고 하자.
그러면 그때마다 직접 세션에서 값을 가져고와 같은 코드가 계속해서 반복되는 불필요한 현상이 일어난다.
그래서 이 부분을 메소드 인자로 세션값을 바로 받을 수 있도록 변경해보겠다.
com.spaws.book.springboot.config.auth 위치에 LoginUser 생성
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
코드 설명 ▼
@Target(ElementType.PARAMETER)
- 이 어노테이션이 생성될 수 있는 위치 지정
- PARAMETER로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용 가능
@interface
- 어노테이션 클래스로 지정
같은 위치에 LoginUserArgumentResolver 생성
@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
private final HttpSession httpSession;
/* supportsParameter()
* isLoginUserAnnotation: 파라미터에 @LoginUser 붙어 있는지
* isUserClass : 파라미터 클래스 타입이 SessionUser.class인지
*/
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
return isLoginUserAnnotation && isUserClass;
}
/* resolveArgument()
* 세션에서 객체를 가져오기
*/
@Override
public Object resolveArgument (MethodParameter parameter, ModelAndViewContainer mavContatiner,
NativeWebRequest ebRequest, WebDataBinderFactory binderFactory) throws Exception {
return httpSession.getAttribute("user");
}
}
코드 설명 ▼
Override한 두 메소드를 살펴보자.
supportsParameter()
- 컨트롤러 메소드의 특정 파라미터를 지원하는지 판단
resolveArgument()
- 파라미터에 전달할 객체 생성
com.spaws.book.springboot.config에 WebConfig 파일 생성
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final LoginUserArgumentResolver loginUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(loginUserArgumentResolver);
}
}
이제 IndexController을 @LoginUser에 맞춰 수정하자.
@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user) {
model.addAttribute("posts", postsService.findAllDesc());
if(user!=null) { // session에 저장된 값이 있을 때만 model에 userName으로 등록
model.addAttribute("userNames", user.getName());
}
return "index";
}
다시 http://localhost:8080/ 에 들어가면 수정 완료된 것을 확인할 수 있다.
또다시 WhiteLabel 에러가 뜬다면 파일 위치나 오타 등을 잘 살펴보자.
세션 저장소로 데이터베이스 사용하기
현재 상태로는 세션이 내장 톰캣의 메모리에 저장되기 때문에 애플리케이션을 재실행하면 로그인이 풀린다.
(내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에선 항상 초기화 = 배포할 때마다 톰캣 재시작)
그래서 실제 현업에서는 다음 세 가지 중 한 가지를 선택한다.
1) 톰캣 세션 사용
- 디폴트
- WAS가 2대 이상일 경우 톰캣 간의 세션 공유를 위한 추가 설정 필요
2) MySQL과 같은 데이터베이스를 세션 저장소로 사용
- 여러 WAS간 공용 세션 사용할 수 있는 가장 쉬운 방법
- 많은 설정 필요 x. 로그인 요청마다 DB IO가 발생 -> 성능 이슈
- 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도에서 사용
3) Redis, Memecahced와 같은 메모리 db를 세션 저장소로 사용
- B2C 서비스에서 많이 사용
- 실제 서비스 사용을 위해 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요
본 프로젝트에서는 2번 방법으로 진행하겠다.
spinrg-session-jdbc 등록
build.gradle에 다음 코드 추가
compile('org.springframework.session:spring-session-jdbc')
application.properties에 다음 코드 추가
spring.session.store-type=jdbc
아직은 코드를 추가함에도 불구하고 스프링을 재시작하면 세션이 풀린다.
h2 기반으로 스프링이 재실행될 때 h2도 재시작되기 때문인데 나중에 AWS로 배포하게 된다면 RDS를 사용할 예정이니 그때부터는 세션이 풀리지 않을 것이다.
해당 게시글은 [ 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 / 이동욱 ] 책을 따라한 것을 정리하기 위한 게시글입니다. 요약, 생략한 부분이 많으니 보다 자세한 설명은 책 구매를 권장합니다.
오류 참고github.com/jojoldu/freelec-springboot2-webservice/issues/549
'Clone Coding > 스프링 부트와 AWS' 카테고리의 다른 글
[Spring Security] 기존 테스트에 시큐리티 적용하기 (0) | 2021.01.22 |
---|---|
[OAuth 2] 네이버 로그인 연동하기 (0) | 2021.01.21 |
[Mustache & Spring] 전체 조회 화면 만들기 (0) | 2021.01.19 |
[Mustache] 게시글 등록 화면 만들기 (0) | 2021.01.19 |
[Mustache] 화면 구성 (0) | 2021.01.18 |