"스프링부트와 AWS로 혼자 구현하는 웹 서비스" 라는 책을 바탕으로 학습목적의 프로젝트를 진행하고 있습니다.
소스 : https://github.com/ironmask431/springboot_aws_01
Chapter 05. 스프링 시큐리티와 OAuth 2.0 으로 로그인 구현하기스프링시큐리티는 막강한 인증과 인가 기능을 가진 프레임워크 입니다.
스프링 시큐리티와 OAuth 2.0 을 구현한 구글 로그인을 연동하여 로그인 기능을 만들어보겠습니다.
5.1 스프링시큐리티와 OAuth2 클라이언트많은 서비스에서 로그인기능을 id/pw 보다는 구글,페이스북,네이버같은 소셜 로그인을 사용합니다.
직접 로그인 기능 만드는 것 보다 OAuth 로그인 을 구현하면 로그인관련 기능을 구글,페이스북,네이버에 맡기면 되니
서비스개발에 집중하기 좋습니다.
5.2 구글 서비스 등록* console.cloud.google.com 접속
프로젝트 선택
→ 새 프로젝트
→ 프로젝트명 입력하기
→ api 및 서비스
→ 사용자 인증정보
→ 사용자 인증정보 만들기
→ OAuth 클라이언트id
→ 동의화면구성
→ 사용자 유형 : 외부선택
→ 앱이름, 사용자지원이메일, 개발자연락처정보(이메일)등록
→ 다음 범위 추가 또는 삭제클릭
→ email, profile, openid 체크선택
→ 테스트사용자등록 + ADD USER
→ 내 이메일 입력..
→ 요약 내용 확인
→ 다시 사용자인증정보
→ 사용자 인증정보 만들기 OAuth 클라이언트id
→ 애플리케이션 유형 : "웹애플리케이션" 선택 / 이름 : "springboot_aws_01"
승인된 리디렉션 URI 추가 "http://localhost:8080/login/oauth2/code/google" 추가
→ 만들기
→ OAuth 클라이언트 ID와 보안비밀번호 확인 (프로젝트에서 사용)
OAuth 클라이언트 ID 생성완료.
* 승인된 리디렉션URI : 서비스에서 파라미터로 인증정보를 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URI 입니다. 스프링부트2 버전의 시큐리티에서는 기본적으로 "{도메인}/login/oauth2/code/{소셜서비스코드}"로 리다이렉트URI를 지원하고 있습니다. 사용자가 별도로 리다이렉트URI 매핑 컨트롤러를 만들지않아도됩니다. 시큐리티에서 이미 구현되어 있습니다.현재는 개발단계이므로 "http://localhost:8080/login/oauth2/code/google" 로만 등록합니다. 실제 배포하게 되면 주소를 추가해야 합니다.* application-oauth 등록
src/main/resources 에 application-oauth.proerperties 생성
spring.security.oauth2.client.registration.google.client-id=여기에 클라이언트id 입력spring.security.oauth2.client.registration.google.client-secret=여기에 클라이언트 보안비밀번호 입력spring.security.oauth2.client.registration.google.scope=profile,emailscope=profile, email 설정이유 미설정 시 기본값은 openid, profile, email 인데openid 라는 scope 가 있으면, open Id Provider 로 인식함. 이렇게 되면 Open Id Provider 인서비스(구글)과 그렇지 않은 서비스(네이버/카카오)로 나눠서 각각OAuth2Service 를 만들어야합니다. 하나의 OAuth2Service를 사용하기 위해 openid scope를 제외하고 등록합니다.스프링부트에서 properties 의 이름을 application-xxxx.properties 로 만들면 xxxx라는 이름의
profile이 생성되어 이를통해 관리 할 수 있습니다.
즉 profile=xxx 식으로 호출하면 해당 properties의 설정들을 가져올 수 있습니다.
application-oauth.proerperties 설정을 사용하기 위해
application.properties 에 코드 를 추가합니다.
spring.profiles.include=oauth* .gitIgnore 등록
OAuth 클라이언트 ID와 보안비밀번호는 중요정보이기 때문에
깃허브에 올라가지 않도록 해야하므로 .gitignore 에 application-oauth.proerperties 를 추가한다.
5.3 구글 로그인 연동하기사용자 정보를 담당할 User 엔티티 클래스 생성
package com.jojodu.book.springboot.domain.user;/** * 사용자 정보 담당 엔티티 */@NoArgsConstructor@Getter@Entitypublic class User extends BaseTimeEntity {@Id@GeneratedValue(strategy = GenerationType.IDENTITY)private Long id;@Column(nullable = false)private String name;@Column(nullable = false)private String email;@Columnprivate String picture;//JPA로 데이터베이스로 저장할때 Enum값을 어떤형태로 저장할지를 결정합니다.//기본적으로는 int로 된 숫자가 저장되자만, 숫자로저장되면 db에서 확인 시 무슨의미인지 알 수없음.//그래서 문자열(EnumType.STRING)로 저장될 수 있도록 선언@Enumerated(EnumType.STRING)@Column(nullable = false)private Role role;@Builderpublic 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();}}각 사용자의 권한을 관리할 Enum 클래스 Role 생성
package com.jojodu.book.springboot.domain.user;/** * 사용자의 권한관리 클래스(enum) */@Getter@RequiredArgsConstructorpublic enum Role {//스프링 시큐리티에서는 권한 코드에 항상 ROLE_ 가 앞에 있어야만합니다.GUEST("ROLE_GUEST","손님"),USER("ROLE_USER","일반 사용자");private final String key;private final String title;}User의 CRUD를 담당할 UserRepository.java 생성
package com.jojodu.book.springboot.domain.user;/** * User의 CRUD 담당 */public interface UserRepository extends JpaRepository {//소셜로그인으로 반환되는 값중 email을 통해 이미 생성된 사용자인지//처음 가입하는 사용자인지 판단하기 위한 메소드//스트림으로 변환하기 위해 리턴타입을 Optional 클래스로 생성Optional findByEmail(String email);}* 스프링시큐리티 설정
build.gradle 에 spring security / oauth2관련 라이브러리 추가
//oauth 추가 (소셜로그인등 클라이언트입장에서 소셜 기능 구현 기능)implementation('org.springframework.boot:spring-boot-starter-oauth2-client')* 소셜 로그인 설정 코드 작성
SecurityConfig.java
package com.jojodu.book.springboot.config.auth;/** * Spring Security + OAuth2 로그인 설정 */@RequiredArgsConstructor@EnableWebSecurity //Spring Security 설정등을 활성화시켜줍니다.public class SecurityConfig extends WebSecurityConfigurerAdapter {private final CustomOAuth2UserService customOAuth2UserService;protected void configure(HttpSecurity http) throws Exception{http.csrf().disable().headers().frameOptions().disable().and().authorizeRequests().antMatchers("/","/css/**","/images/**","/js/**","/h2-console/**").permitAll().antMatchers("/api/v1/**").hasRole(Role.USER.name()).anyRequest().authenticated().and().logout().logoutSuccessUrl("/").and().oauth2Login().userInfoEndpoint().userService(customOAuth2UserService);}/** * .headers().frameOptions().disable() = h2-console 화면을 사용하기 위해 해당옵션들을 disable합니다. * .authorizeRequests() = URL별 권한 관리를 설정하는 옵션의 시작점입니다. 이후 antMatchers() 옵션을 사용가능합니다. * .antMatchers() = 권한 관리 대상을 지정하는 옵션입니다.URL,HTTP 메소드별로 관리가 가능합니다. * .anyRequest().authenticated() = 설정값 이외의 url에 대한 설정입니다. 이외의 url은 모두 인증된 사용자에게만 허용합니다. * .and().logout().logoutSuccessUrl("/") = 로그아웃 성공시 "/"로 이동 * .oauth2Login() = oauth2 로그인 설정의 시작점 * .userInfoEndpoint() = oauth2 로그인 성공 후 사용자정보를 가져올때 설정담당 * .userService() = 소셜로그인성공후 후속조치를 진행할 userService 인터페이스의 구현제등록 * 소셜서비스에서 사용자정보를 가져온 상태에서 추가로 진행하고자하는 기능을 명시할 수 있음 */}OAuthAttributes.java
package com.jojodu.book.springboot.config.auth.dto;/** * OAuth2UserService 를 통해서 가져온 oAuth2User의 attribute를 담을 클래스 */@Getterpublic class OAuthAttributes {private Map attributes;private String nameAttributeKey;private String name;private String email;private String picture;@Builderpublic OAuthAttributes(Map attributes, String nameAttributeKey,String name, String email, String picture){this.attributes = attributes;this.nameAttributeKey = nameAttributeKey;this.name = name;this. email = email;this. picture = picture;}//OAuth2User 정보를 OAuthAttributes에 입력public static OAuthAttributes of(String registrationId, String userNameAttributeName,Map attributes){return ofGoogle(userNameAttributeName, attributes);}//OAuth2User 에서 반환하는 사용자정보는 Map이기 때문에 하나하나 변환해야합니다.public static OAuthAttributes ofGoogle(String userNameAttributeName,Map attributes){return OAuthAttributes.builder().name((String)attributes.get("name")).email((String)attributes.get("email")).picture((String)attributes.get("picture")).attributes(attributes).nameAttributeKey(userNameAttributeName).build();}//User Entity를 생성합니다.//OAuthAttributes 에서 엔티티를 생성하는 시점(DB insert)은 처음 로그인할때입니다.//최초 생성할때의 기본 권한을 GUEST로 줍니다.public User toEntity(){return User.builder().name(name).email(email).picture(picture).role(Role.GUEST).build();}}SessionUser.java
package com.jojodu.book.springboot.config.auth.dto;/** * 세션에 사용자정보 담기위한 클래스 */@Getterpublic class SessionUser implements Serializable {//session에 담기위해 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 Entity클래스를 바로 저장하면 안되는 이유. * -> User클래스에 직렬화를 구현하지 않았으므로, Failed to Converto from type... 에러발생 * 엔티티클래스 자체를 직렬화 한다면 여러가지 문제가 생길 수 있음. * @OneToMany, @ManyToMany 등 자식엔티티를 가지고있다면 자식까지 직렬화되어 * 성능이슈, 부수효과가 발생 할 수 있음. * 그래서 직렬화 기능을 가진 세션Dto(SessionUser)를 따로 만드는것이 운영유지보수에 좋음. */}CustomOAuth2UserService.java
package com.jojodu.book.springboot.config.auth;/** * OAuth2 로그인 이후 가져온 사용자의 정보(email, name, picture 등) * 을 기반으로 가입, 정보수정, 세션저장 등의 기능 담당 * OAuth2UserService 인터페이스를 구현 */@RequiredArgsConstructor@Servicepublic class CustomOAuth2UserService implements OAuth2UserService {private final UserRepository userRepository;private final HttpSession httpSession;public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {OAuth2UserService delegate = new DefaultOAuth2UserService();OAuth2User oAuth2User = delegate.loadUser(userRequest);//OAuth2UserService 를 통해 oAuth2User(로그인한 유저) 정보를 가져옴.String registrationId = userRequest.getClientRegistration().getRegistrationId();//현재 로그인 진행중인 서비스를 구분하는 코드. (구글, 네이버(=naver), 카카오(=kakao)등)String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();//OAuth2 로그인 진행 시 키가되는 필드값. PK와 같은 의미//구글의 경우 기본적으로 코드지원하지만, 네이버,카카오는 지원하지않음 구글의 기본코드는 "sub"//네이버 카카오는 application-oauth.properties 에 정의한 user-name-attribute 값으로 설정됨.OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName,oAuth2User.getAttributes());//OAuthAttributes = OAuth2UserService 를 통해서 가져온 oAuth2User의 attribute를 담을 클래스입니다.User user = saveOrUpdate(attributes);//로그인 user정보 DB insert or update 실행 httpSession.setAttribute("user",new SessionUser(user));//SessionUser = 세션에 사용자정보 담기위해 만든 클래스return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),attributes.getAttributes(),attributes.getNameAttributeKey());}//구글 사용자정보가 업데이트되었을때를 대비하여 update 기능도 같이구현.//사용자의 이름, 프로필사진이 변경되면 User엔티티에 반영됨private User saveOrUpdate(OAuthAttributes attributes){User user = userRepository.findByEmail(attributes.getEmail()).map(entity -> entity.update(attributes.getName(), attributes.getPicture())).orElse(attributes.toEntity());//attributes의 이메일로 user정보를 userRepository통해서 조회한다음, 일치하는 정보가 있으면//해당 유저의 name과 picture를 업데이트하고, 해당 유저정보를 user 엔티티로 반환.//일치하는 정보가 없으면 attributes의 정보로 새로운 user 엔티티를 만들어서 (DB insert) 반환.return userRepository.save(user);}}* 로그인 테스트
index.mustache 수정
글 등록{{#userName}} Logged in as : {{userName}}Logout{{/userName}}{{^userName}} Google Login{{/userName}}머스테치는 다른언어와 같은 if문 을 제공하지않음 ( true, false만 반환 ){{#userName}} ~ {{/userName}} = userName 이 있을 경우{{^userName}} ~ {{/userName}} = userName 이 없을 경우/logout = 스프링시큐리티에서 기본제공하는 로그아웃 url 입니다. 별도록 컨트롤러 만들필요없음./oauth2/authorization/google = 스프링시큐리티에서 기본제공하는 로그인url 입니다. 별도로 컨트롤러 만들필요없음.IndexController.java 에 UserName을 model에 추가하는 코드 추가
@GetMapping("/")public String index(Model model){model.addAttribute("posts", postsService.findAllDesc());SessionUser user = (SessionUser) httpSession.getAttribute("user");if(user != null){model.addAttribute("userName",user.getName());}return "index";}localhost:8080/ 접속
로그인버튼 클릭
로그인 성공
h2-console 에서 회원가입이 되어 있는지 확인
글 작성 테스트 시 현재 로그인한 사용자의 권한이 GUEST이기 때문에
SecurityConfig에 설정한대로 "/api/v1/**" api url은 허용되지 않아서 "403" 에러 발생
해당 유저의 ROLE 를 USER로 업데이트
로그아웃 후 재로그인 (세션의 ROLE을 새로고침하기위해) 후 글등록 정상 확인
5.4 네이버 로그인* 네이버 로그인 API 이용 등록
https://developers.naver.com/products/login/api/api.md
애플리케이션 이름 : springboot_aws_01
네이버로그인 선택
사용 api 체크 : 회원이름 / 이메일 / 프로필사진 선택
환경추가 : pc 웹
서비스 url : http://localhost:8080/
네이버아이디 로그인 콜백 : (구글에서 등록한 리디렉션url과 같은 역할)
http://localhost:8080/login/oauth2/code/naver
client ID / Client Secret 정보 확인
* application-oauth.properties 에 네이버 로그인 정보 등록
// naver loginspring.security.oauth2.client.registration.naver.client-id=여기에 client_idspring.security.oauth2.client.registration.naver.client-secret=여기에 client_secret// localhost:8080/login/oauth2/code/naver (스프링 시큐리티에서 기본제공하는 형태)spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_codespring.security.oauth2.client.registration.naver.scope=name,email,profile_imagespring.security.oauth2.client.registration.naver.client-name=naver// 네이버 로그인 인증 요청 urispring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize// 네이버 접근 토큰의 발급,갱신,삭제 요청 urispring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token// 네이버 회원의 프로필을 조회 urispring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me// 기준의 되는 user-name-attribute 의 이름을 response로 등록// 실제 유저 키깂은 reponse.id 이지만 스프링 시큐리티에서는 하위필드를 명시 할 수 없으므로, // 상위필드인 response라고 명시spring.security.oauth2.client.provider.naver.user-name-attribute=response* 유저정보 조회 api response 형태
{ "resultcode": "00", "message": "success", "response": {"email": "openapi@naver.com",//email : 회원의 네이버아이디가 아님. 회원가입시 별도등록한 다른 email "nickname": "OpenAPI","profile_image": "https://ssl.pstatic.net/static/pwe/address/nodata_33x33.gif","age": "40-49","gender": "F","id": "32742776",//유저의 고유 key값. 같은 사용자라도 요청하는 애플리케이션에 따라 다른값."name": "오픈 API","birthday": "10-01","birthyear": "1900","mobile": "010-0000-0000" }}* OauthAttributes.java 에 네이버 설정 등록
//OAuth2User 정보를 OAuthAttributes에 입력public static OAuthAttributes of(String registrationId, String userNameAttributeName,Map attributes){//registrationId = 로그인 서비스 구분 코드//String = userNameAttributeName 은 사용자정보 조회 응답 json에서 유니크키 값.//구글은 "sub"로 자동//카카오 네이버는 application-oauth.properties 에 설정한 user-name-attribute 값if("naver".equals(registrationId)){return ofNaver(userNameAttributeName, attributes);}else if("kakao".equals(registrationId)){return ofKakao(userNameAttributeName, attributes);}return ofGoogle(userNameAttributeName, attributes);}//naverpublic static OAuthAttributes ofNaver(String userNameAttributeName,Map attributes){//네이버의 경우 회원정보로 리턴받은 json형태가 response 내부에 유저정보가 들어있으므로 아래 코드추가함.Map response = (Map)attributes.get("response");//네이버의 경우 userNameAttributeName 이 "response"로 되어있는데 실제 키값은 response안에 id 이므로, id라고 변경해줌.userNameAttributeName = "id";return OAuthAttributes.builder().name((String)response.get("name")).email((String)response.get("email")).picture((String)response.get("profile_image")).attributes(response) //user 정보 map.nameAttributeKey(userNameAttributeName) // user정보map의 pk필드 (네이버 = id).build();}* index.mustache 에 네이버 로그인 버튼 추가
Naver Login* 로그인테스트
정상 로그인 확인.
5.5 카카오 로그인카카오 로그인 연동은 네이버와 유사하다.
카카오 디벨로퍼 접속 : https://developers.kakao.com/
내 애플리케이션 > 애플리케이션 추가하기 > 앱이름, 사업자명 등록
REST API 키 확인 (구글,네이버의 client_id 와 같은 역할)
앱설정 > 플랫폼 > web 플랫폼 등록
사이트도메인 : http://localhost:8080
제품설정 > 카카오 로그인 > 활성화 설정 on > Redirect URI 생성 > http://localhost:8080/login/oauth2/code/kakao
제품설정 > 카카오 로그인 > 동의항목 > 닉네임(필수), 프로필사진(선택), 카카오계정(이메일)(선택) 으로 설정함. 제품설정 > 보안 > client secret : 카카오는 client secret이 선택사항이므로 패스 제품설정 > 고급 > Logout Redirect URI : 스프링시큐리티에서 로그아웃은
"/logout" 으로 기본설정되어 있으니 패스한다.
* application-oauth.properties 에 카카오 로그인 정보 등록
//kakao loginspring.security.oauth2.client.registration.kakao.client-id=여기에 REST API 키// localhost:8080/login/oauth2/code/kakao (스프링 시큐리티에서 기본제공하는 형태)spring.security.oauth2.client.registration.kakao.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_codespring.security.oauth2.client.registration.kakao.scope=profile_nickname,profile_image,account_emailspring.security.oauth2.client.registration.kakao.client-name=kakao//kakao의 경우 별도로 추가해줘야함.spring.security.oauth2.client.registration.kakao.client-authentication-method=POST// 카카오 로그인 인증 요청 urispring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize//카카오 접근 토큰의 발급,갱신,삭제 요청 urispring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token//카카오 회원의 프로필을 조회 urispring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me//카카오 회원정보 조회 응답 json 형태 확인하여 pk키값 필드 입력spring.security.oauth2.client.provider.kakao.user-name-attribute=id* 처음 카카오 로그인 시 [invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: 401 Unauthorized 오류 발생 구글링해보니 혹시, application.yml 설정에서 “client-authentication-method: POST” 설정이 있는지도 확인 해주세요~application.properties 에 추가하고 정상 로그인됨.
* 유저정보 조회 api response 형태
{"id":123456789, // key값"kakao_account": { "profile_needs_agreement": false,"profile": {"nickname": "홍길동","thumbnail_image_url": "http://yyy.kakao.com/.../img_110x110.jpg","profile_image_url": "http://yyy.kakao.com/dn/.../img_640x640.jpg","is_default_image":false},"name_needs_agreement":false, "name":"홍길동","email_needs_agreement":false, "is_email_valid": true,"is_email_verified": true,"email": "sample@sample.com","age_range_needs_agreement":false,"age_range":"20~29","birthday_needs_agreement":false,"birthday":"1130","gender_needs_agreement":false,"gender":"female"}, "properties":{"nickname":"홍길동카톡","thumbnail_image":"http://xxx.kakao.co.kr/.../aaa.jpg","profile_image":"http://xxx.kakao.co.kr/.../bbb.jpg","custom_field1":"23","custom_field2":"여"...}}* OauthAttributes.java 에 카카오 설정 등록
//OAuth2User 정보를 OAuthAttributes에 입력public static OAuthAttributes of(String registrationId, String userNameAttributeName,Map attributes){//registrationId = 로그인 서비스 구분 코드//String = userNameAttributeName 은 사용자정보 조회 응답 json에서 유니크키 값.//구글은 "sub"로 자동//카카오 네이버는 application-oauth.properties 에 설정한 user-name-attribute 값if("naver".equals(registrationId)){return ofNaver(userNameAttributeName, attributes);}else if("kakao".equals(registrationId)){return ofKakao(userNameAttributeName, attributes);}return ofGoogle(userNameAttributeName, attributes);}//kakaopublic static OAuthAttributes ofKakao(String userNameAttributeName,Map attributes){//카카오 회원정보 조회 api 리턴 json 형태를 보고 코드 작성.Map kakao_account = (Map)attributes.get("kakao_account");Map profile = (Map)kakao_account.get("profile");//profile 에 유저정보가 들어있지만, email과 id(pk)는 밖에있으므로, profile에 추가해준다.profile.put("email",kakao_account.get("email"));profile.put("id",attributes.get("id"));return OAuthAttributes.builder().name((String)profile.get("nickname")).email((String)profile.get("email")).picture((String)profile.get("profile_image_url")).attributes(profile) //user 정보 map.nameAttributeKey(userNameAttributeName) // user정보map의 pk필드 (카카오 = id).build();}* index.mustache 에 카카오 로그인 버튼 추가
kakao Login* 로그인테스트
정상로그인 확인.