소셜 로그인 서비스를 구현하기 위해 GitHub API에서 제공하는 OAuth2.0 을 스프링 시큐리티 없이 구현해보려고 한다. ☺️
기록 시작 ~
신규 OAuth API 등록하기
가장 먼저 할 일은, GitHub 에 접속하고 Setting -> Developer settings -> oAuth App -> new Oauth app 버튼을 클릭해서
나의 OAuth App 을 등록해야 한다.
버튼을 클릭하면 어려가지 정보를 작성핼 수 있는데, 필수 칸은 무조건 적어줘야 한다.
1. Application name (필수)
- 원하는 어플리케이션 이름을 적어주면 된다.
2. Homepage URL (필수)
- 개발하는 웹 페이지의 홈페이지 주소를 적어준다.
3. Application description (생략 가능)
- 설명을 간략하게 적어준다.
4. Authorization callback URL (필수)
- https://http:github.com/login/oauth/authorize 를 거쳐서 임시 코드(Authorization Code) 를 받아내는 주소를 적어준다.
API 등록 후
필수 정보를 모두 기입하고 등록에 성공했다면 가장 중요한 정보인 Client ID, Client secrets 를 얻을 수 있다.
(원래 Client secrets 정보는 아무한테도 보여주며 안된다 ㅎ.ㅎ ☺️)
Spring Boot 로 구현하기
기록을 해서 다시 봐야하니까 그 때도 빠르게 이해할 수 있도록 최대한 쉽게 구현해봤다.
MVC 패턴을 사용해서 Controller / Service / Repository 등 클래스, 인터페이스를 생성해서 진행했다.
1. application.yml 파일 설정
객체에서 @Value 어노테이션을 사용해 편리하게 값을 사용하기 위해 미리 정의해주는 값.
client-id, client-secret 는 OAuth API 를 만들고 받은 자신의 값을 적어주면 된다. 나머지 값들은 GitHub OAuth 에서 정의해준
값들이니 요거 그대로 적어도 무방하다 !
spring:
... 중략
(spring 과 같은 Index)
oauth2:
user:
github:
client-id: ~
client-secret: ~
login-url: https://github.com/login/oauth/authorize
redirect-url: http://localhost:8080/login/oauth
token-url: https://github.com/login/oauth/access_token
user-url: https://api.github.com/user
2. 발급받은 AccessToken 정보를 저장할 DTO 클래스 생성
공식문서에 따르길, Json 타입으로 AccessToken 을 받으면 요런 형태로 응답을 받을 수 있다.
따라서 AccessToken 을 저장하는 인스턴스의 변수도 해당 Json 데이터에 맞춰서 생성하면 된다.
Json 파싱을 위해 생성자에 @JsonCreator, 생성자 매개변수에 @JsonProperty 어노테이션을 추가한다. (없으면 파싱 에러 🥲)
{
"access_token":"~",
"token_type":"bearer"
}
@Getter
public class AccessToken {
private final String accessToken;
private fianl String tokenType;
@JsonCreator
public AccessToken(
@JsonProperty("access_token") String accessToken,
@JsonProperty("token_type") String tokenType) {
this.accessToken = accessToken;
this.tokenType = tokenType;
}
}
3. 사용자 수락 까지 LoginController
LoginContorller 와 LoginService 의 의존관계를 생성자를 통해 설정한다.
최초 사용자가 /login 으로 요청을 보내면, 서버에서 ClientID, RedirectUrl 등 정보를 OAuth 쪽으로 보내서 임시 Code 를 받아올 수
있다. 이 작업을 LoginService 의 getCode() 에서 진행하도록 하자.
@RestController
public class LoginController {
private final LoginService loginService;
public LoginController(LoginService loginService) {
this.loginService = loginService;
}
@GetMapping("/login")
public RedirectView getCode(RedirectAttributes redirectAttributes) {
return loginService.requestCode(redirectAttributes)
}
}
4. 사용자 수락 까지 LoginService
application.yml 파일에 미리 정의한 값들을 @Value 어노테이션을 사용해 가져올 수 있다. (생성자를 통해 인스턴스 변수 셋팅)
지금 단계에서는 clientId, redirect_uri (필수 값), state (권장 값) 을 GET https://github.com/login/oauth/authorize (loginUrl)
경로로 보내면 된다. state 값은 필수 값은 아니지만, 추측할 수 없는 임의의 랜덤 값으로, 요청 위조 공격으로부터 보호하는데 사용할 수
있는 값이기 때문에 나는 넣어줬다.
requestCode() 메서드가 종료되면서 loginUrl 경로로 리다이렉트 !
String clientId - GitHub 에서 받은 내 클라이언트 ID
String redirectUrl - 사용자의 승인 후, 전송되는 URL
String state - 임의의 랜덤 값 (여기서는 UUID 를 생성해서 셋팅하고 사용한다.)
@Service
public class LoginService {
private String clientId;
private String redirectUrl;
private String loginUrl;
private String state;
public LoginService(
@Value("${oauth2.user.github.client-id}") String clientId,
@Value("${oauth2.user.github.redirect-url}") String redirectUrl,
@Value("${oauth2.user.github.login-url}") String loginUrl) {
this.clientId = clientId;
this.redirectUrl = redirectUrl;
this.loginUrl = loginUrl;
this.state = UUID.randomUUID().toString();
}
public RedirectView requestCode(RedirectAttributes redirectAttributes) {
redirectAttributes.addAttribute("client_id", clientId);
redirectAttributes.addAttribute("redirect_url", redirectUrl);
redirectAttributes.addAttribute("state", state);
return new RedirectView(loginUrl);
}
}
5. 사용자의 수락 이후 토큰 발매 까지 LoginContorller
loginUrl 로 리다이렉트 하고 드디어 임시 Code 를 받을 수 있는데, 나는 위 과정에서 String state 값 또한 보냈기 때문에 같이 받게 된다.
쿼리 스트링 형태로 임시 Code 등의 정보가 오게되는데, API 설정 시 Callback URL 뒤에 붙어져서 보내지게 된다.
(내 Callback URL 은 http://localhost:8080/login/oauth 이니까, /login/oauth?code= ~ & state= ~ 이런식으로)
따라서 Callback URL 를 컨트롤러를 하나 만들어주고 @RequestParam 어노테이션을 사용해 code, state 를 사용할 수 있도록 한다.
... 코드 중략
@GetMapping("/login/oauth")
public ResponseEntity<HttpHeaders> getToken(
@RequestParam String code,
@Requestparam String state,
HttpServletRequest request) {
... 중략
}
6. LoginController 의 getToken() / LoginService 의 getAccessToken() 내부 구현
이제 임시 code 를 사용해 AccessToken 을 발급받아보자.
공식 문서에 따르면, 사용자의 수락 후 POST https://github.com/login/oauth/access_token 로 다시 리다이렉션 하라고 하는데,
이 때 필수 매개변수로 clientID, clientSecret, code, redirectUrl 을 함께 보내주면 AccessToken 의 정보를 얻을 수 있다.
1. 필수 매개변수는 Map<String, String> 을 선언해 넣어준다.
2. 미리 만들어놓은 AccessToken DTO 에 Json 타입으로 받은 데이터를 파싱한다.
3. Json 으로 데이터를 받아오기 위해 HttpHeader 를 선언해 Accept 를 Json 으로 설정한다. (내부에 List 를 받는거라 List.of 사용)
4. HttpEntity 를 선언해 필수 매개변수를 담은 headers, bodies 를 담는다.
5. POST https://github.com/login/oauth/access_token 으로 요청을 담아 보낸 뒤, 응답의 body 를 리턴하면 끝.
6. 이제 AccessToken DTO 에 값이 잘 담겨있는 것을 확인할 수 있다.
--- LoginController ---
@GetMapping("/login/oauth")
public ResponseEntity<HttpHeaders> getToken(
@RequestParam String code,
@RequestParam String state,
HttpServeltRequest request) {
AccessToken accessTokenInfo = loginService.getAccessToken(code, state);
}
--- LoginService ---
public AccessToken getAccessToken(String code, String state) {
Map<String, String> bodies = new HashMap<>();
bodies.put("client_id", clientId);
bodies.put("client_secret", clientSecret);
bodies.put("code", code);
bodies.put("state", state);
HttpHeaders headers = new HttpHeaders();
headers.setAccept(List.of(MediaType.APPLICATION_JSON);
HttpEntity<Object> request = new HttpEntity<>(bodies, headers);
ResponseEntity<AccessToken> response = new RestTemplate()
.postForEntity(tokenUrl, request, AccessToken.class);
return response.getBody();
}
7. AccessToken 을 활용해 해당 유저의 GItHub 정보를 불러오기
이제 받아온 토큰을 활용해 GItHub 정보를 가져와보자.
AccessToken DTO 와 비슷하게 DTO 를 하나 더 생성한다.
인스턴스 변수와 파싱하는 변수 이름이 서로 다른데, 이건 GitHub OAuth 에서 보내주는 값이 알아보기 힘들어서 임의로 바꾼 것 ㅎㅎ ☺️..
OAuth 에서는 login 값이 username, id 값이 유저 일련번호, name 값이 별명으로 오기 때문에 좀 알아보기 쉽게 바꿔줬다.
예를 들면, login = juni8453, id = 1253123, name = Jeon(Tany) 이런 식으로 ..
@Getter
public class GitHubUserInfo() {
private final String username;
private final String idNumber;
private final String nickname;
@JsonCreator
public GitHubInfo(
@JsonProperty("login") String username,
@JsonProperty("id") String idNumber,
@JsonProperty("name") String nickname) {
this.username = username;
this.idNumber = idNumber;
this.nicknam = nickname;
}
}
다시 LoginController 의 getToken() 메서드에 Github 유저의 정보를 가져오는 로직을 추가하고, LoginService 에서
getGitHubUserInfo() 메서드를 추가하자.
공식 문서에 따르면,
1. Header 에 Authorization 을 아래 형식으로 추가하고 2번 URL 로 보낸다.
2. GET https://api.github.com/user
따라서 HttpHeaders 를 선언하고 똑같은 형식으로 나의 AccessToken 을 담아주고 GET 방식으로 Json 데이터를 파싱받아오기 위해
위에서 AccessToken 을 파싱하기 위해 사용한 postForEntity() 가 아닌 exchage() 메서드를 사용해 GitHubUserInfo 에 파싱한다.
(내부 인자로 Httpmethod.GET 을 꼭 넣어줘야 한다 !)
--- LoginController
@GetMapping("/login/oauth")
public ResponseEntity<HttpHeaders> getToken(
@RequestParam String code,
@RequestParam String state,
HttpServletRequest request) {
AccessToken accessTokenInfo = loginService.getAccessToken(code, state);
GitHubUserInfo gitHubUserInfo = loginService.getGitHubUserInfo(accessTokenInfo);
... 중략
}
--- LoginService
public GitHubUserInfo getGitHubUserInfo(AccessToken accessTokenInfo) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "token " + accessTokenInfo.getAccessToken());
HttpEntity<Object> request = new HttpEntity<>(headers);
ResponseEntity<GitHubUserInfo> response = new RestTemplate()
.exchage(userUrl, Httpmethod.GET, request, GitHubUserInfo.class);
return response.getBody();
}
여기까지 잘 진행됐으면 AccessToken 과 GitHubUserInfo DTO 내부에 값이 잘 저장됐다.
이제 진짜 로그인을 위해 Session 을 만들고 값을 셋팅한 뒤 DB 에 유저를 저장해보자.
1. Session 셋팅
HttpSession 객체를 사용해 간단히 Session 을 만들 수 있다.
만든 Session 내부에 GitHubUserInfo DTO 의 값을 원하는 Key 값으로 저장하면 된다.
@GetMapping("/login/oauth")
public ResponseEntity<HttpHeaders> getToken(
@RequestParam String code,
@RequestParam String state,
HttpServletRequest request) {
AccessToken accessTokenInfo = loginService.getAccessToken(code, state);
GitHubUserInfo gitHubUserInfo = loginService.getGitHubUserInfo(accessTokenInfo);
HttpSession session = new HttpSession();
session.setAttribute("userInfo", gitHubUserInfo);
... 중략
}
2. DB 에 User 저장하기
DB 에 저장하기 전에 User Entity 를 먼저 만들어주자. 나는 Spring Data JPA 를 사용했다.
학습용이니까 간단하게 PK 값인 id, 이름을 담을 수 있게 username 만 필드에 선언하고 생성자 인자로 GitHubUserInfo 객체를
받을 수 있도록 하자.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id @GeneratedValue
private Long id;
@Column(nullable = false)
private String username
public User(GitHubUserInfo gitHubUserInfo) {
this.username = gitHubUserInfo.getId();
}
}
JpaRepository 인터페이스를 상속받는 UserRepository 인터페이스를 생성해주자. (JPA 을 사용하기 때문에)
나중에 로그인 검증을 위해 사용하는 쿼리 메서드인 findByUsername() 를 미리 만들어놓자.
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
LoginController 로 돌아와서 인자로 GitHubUserInfo 객체를 가진 loginService.saveMember() 메서드를 호출한다.
LoginService 에서는 의존 주입을 위해 인스턴스 변수, 생성자에 UserRepository 를 추가한다.
이제 LoginController 에서 saveMember() 메서드가 호출되면서,
1. 인자로 받아온 GitHubUserInfo 객체를 활용해 새로운 User 객체를 생성한다.
2. 리다이렉트를 위해 HttpHeaders 객체를 생성하고, Location 을 홈페이지로 맞춰준다.
3. header 를 ResponseEntity 객체에 넣고 return !
(HttpStatus.SEE_OTHER 은 리다이렉트를 위해 넣어준 값인데, header 에 Location 값이 있는데 굳이 넣어줘야하는지 ..? 정확히
몰라서 좀 더 알아봐야 하는 부분 😵💫)
--- LoginController
... 중략
@GetMapping("/login/oauth")
public ResponseEntity<HttpHeaders> getToken(
@RequestParam String code,
@RequestParam String state,
HttpServletRequest request) {
AccessToken accessTokenInfo = loginService.getAccessToken(code, state);
GitHubUserInfo gitHubUserInfo = loginService.getGitHubUserInfo(accessTokenInfo);
HttpSession session = new HttpSession();
session.setAttribute("userInfo", gitHubUserInfo);
return loginService.saveMember(gitHubUserInfo);
}
--- LoginService
private final UserRepository userRepository;
... 중략
public LoginService (
UserRepository userRepository,
... 중략) {
this.userRepository = userRepository;
... 중략
}
public ResponseEntity<HttpHeaders> saveMember(GitHubUserInfo gitHubUserInfo) {
userRepository.save(new User(gitHubUserInfo));
HttpHeaders headers = new HttpHeaders();
headers.setLocation(URI.create("http://localhost:8080/"));
return new ResponseEntity<>(headers, HttpStatus.SEE_OTHER);
}
3. Session 검증
무탈히 진행되었다면, 서버에 Session 이, DB 에 새로운 User 가 저장되면서 "localhost:8080/" URL 로 리다이렉트 될 것이다.
이제 해당 Session 을 검증하는 과정을 진행해보자.
먼저 "localhost:8080:/" 을 매핑하는 Controller 를 하나 만들어준다. 검증 부분에서는 두 번의 검증을 실시하게 된다.
1. 첫 번째 검증 - 세션이 존재하는가 ?
2. 두 번째 검증 - 또는 Session 값에서 얻어온 Username 과 일치하는 User 가 존재하는가 ?
만약, 무사히 해당 분기문을 통과하게 되면 Home Page 로 이동하고, 그렇지 못하면 다시 로그인 페이지로 이동하게 된다.
이 부분은 GlobalException 처리를 통해 예외처리를 할 수도 있는데, 이 부분이 중요한게 아니라고 판단해서 슈도 코드 처럼 짜봤다.
아마 Interceptor 를 활용할 수도 있을 것 같다 !
@GetMapping("/")
public ResponseEntity<String> home(HttpServletResponse response, HttpSession session) {
GitHubUserInfo checkSession = (GitHubUserInfo) session.getAttribute("userInfo");
if((checkSession == null) ||
userRepository.findByUsername(checkSession.getUsername()).isEmpty()) {
try {
response.sendRedirect("http://localhost:8080/loginPage");
} catch(Exception e) {
e.printStackTrace();
}
}
return ResponseEntity.ok("Home Page 입니다.");
}
저번 프로젝트 때 공부한 GitHub OAuth2.0 를 정리해봤는데, 아직 모자란 부분이 많고 프론트 하고 통신을 해보지 않아서 추가로 알게될
부분이 많은 것 같다. 그래도 일단 쭉 사이클을 돌려서 구현해봤다는 것에 의의를 두기로 ~~ 😃
'SPRING' 카테고리의 다른 글
Custom Exception 활용법 (0) | 2023.06.10 |
---|---|
[Spring Security] Authentication 인증 처리 과정 알아보기 (0) | 2022.12.17 |
DTO 반환에 대해 (4) | 2022.10.13 |
Spring @Validation 을 통한 검증, 예외처리 도전기 (0) | 2022.04.16 |