우선 이 작업이 해당 프로젝트의 첫 작업은 아니다. 간단한 구현은 이미 진행했었고, 이 github link에서 본 작업 이전까지의 작업물을 확인할 수 있다.( 아 물론 코드를 받아서 사용하려면 application.properties에 적혀있는 mysql 설정에 맞게 DB를 만들어줘야 한다.)
본 글은 OAuth2를 처음 적용해보며 겪고 이해했던 내용들을 복습의 형태로 기록하는 목적을 갖고 있다. 또한 본 글은 이 포스트를 따라가며 kotlin으로 변화시켜 적용시켜 보는 과정임을 명시한다.
우선 OAuth2에 대한 자세한 설명은 여기서 하지 않는다. 대신 이해에 큰 도움이 될 수 있는 글 link를 하나 달아둔다.
OAuth2 인증 방식에 대해 알아보자.
처음으로는 구글 프로젝트를 만들고 나의 프로젝트와 연결해줘야 한다.
구글 프로젝트를 생성하고 사용자 ID를 만드는 것 까지는 따로 적지 않아도 구글링을 통해 너무나도 쉽게 알아낼 수 있기에 넘어간다.
코드적으로 진행해야 하는 부분은 아래와 같다.
## application.properties
~
spring.profiles.include=oauth 
~
## application-oauth.properties (여기서 해당 파일은 개인 key이기에 .gitignore를 통해서 제외시켜주자)
spring.security.oauth2.client.registration.google.client-id=[발급받은 client-id]
spring.security.oauth2.client.registration.google.client-secret=[발급받은 client-secret]
spring.security.oauth2.client.registration.google.scope=profile,email
서비스에서 사용할 User Entity를 만들어준다. 해당 코드는 우선 email, name, picture, role, createdAt field들을 가지고 있고, password가 없는 이유는 일단 google login을 통해서만 user가 사용할 수 있도록 할 생각이기 때문이다.
## User.kt
@Entity
data class User(
    @Column (unique = true)
    val email: String,
    val name: String,
    val picture: String,
    @Enumerated(EnumType.STRING)
    val role: Role,
    @CreationTimestamp
    val createdAt: LocalDateTime = LocalDateTime.now(),
    @Id @GeneratedValue
    val id: Long = 0
)
enum class Role(
    val key: String,
    val title: String
) {
    ADMIN("ROLE_ADMIN", "관리자"), 
    USER("ROLE_USER", "사용자")
}
## UserRepository.kt
interface UserRepository: JpaRepository<User, Long> {
    fun findByEmail(email: String): User?
}
자 이제부터가 OAuth2 적용의 시작이다. 먼저 해야할 일은 gradle에 oauth를 추가해주는 것이다.
## build.gradle.kts
~
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
~
이제 핵심 코드들을 나온다. 우선 한번 쭉 읽고서 따라 적거나, 다시 한번 보는 걸 추천한다. 그 이유는 먼저 나오는 코드에서 뒤 쪽에 나올 코드를 사용하기 때문이다. 그럼에도 이 순서대로 코드를 배치한 것은 그게 이해에 도움이 될 것 같아서 이다.
이 class는 HttpSecurity를 설정해주는 역할을 한다. 코드를 보면서 이해해보자.
@EnableWebSecurity
class SecurityConfig(
    @Autowired private val customOAuth2UserService: CustomOAuth2UserService
): WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity?) {
        if (http == null) throw Exception()
        http
            .csrf().disable() 
            .headers().frameOptions().disable()
            // 여기까지는 h2 콘솔을 위한 설정들을 disable 하는 과정이라고 한다.
            // 이 파트는 사실 이해하지 못했다. 다만 csrf가 나오는 것으로 보아
            // 후에 다시 와서 수정하게 될 것 같다.
            .and()
                .authorizeRequests() // URL별 접근 권한 설정을 시작함을 알림
                .antMatchers("/").permitAll() // "/"는 누구나에게
                .antMatchers("/api/**").hasRole(Role.USER.name) // "/api/**"는 USER 권한을 가진 사람만
                .anyRequest().authenticated() // 이외의 곳들은 로그인한 사람이라면 누구
            .and()
                .logout() // 로그아웃 관련 설정을 하겠다~
                .logoutSuccessUrl("/") // 로그아웃 성공시 "/"으로 redirect
            .and()
                .oauth2Login() // OAuth2 로그인 기능을 설정하겠다~
                .userInfoEndpoint() // userInfo Endpoint, 즉 로그인 성공 후에 관하여 설정
                .userService(customOAuth2UserService) // 로그인 성공후에 사용할 Service 등록
    }
}
이렇게 이 과정은 http 통신에 있어서 Security 설정을 전반적으로 설정하는 코드가 된다. 이에 이어서는 마지막에 userService로 등록하는 customOAuth2UserService를 작성한다.
@Service
class CustomOAuth2UserService(
    private val userRepository: UserRepository,
    private val httpSession: HttpSession
): OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    override fun loadUser(userRequest: OAuth2UserRequest?): OAuth2User {
        if (userRequest == null) throw OAuth2AuthenticationException("Error")
        val delegate = DefaultOAuth2UserService()
        val oAuth2User = delegate.loadUser(userRequest)
		// registrationId는 로그인 진행중인 서비스 코드
        // 구글, 네이버, 카카오등을 구분하는 것이기에 현재는 사실 필요없음
        val registrationId = userRequest.clientRegistration.registrationId
        // OAuth2 로그인 진행시 키가 되는 필드값
        val userNameAttributeName = userRequest.clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName
        
        // OAuth2User의 attribute가 된다.
        // 추후 다른 소셜 로그인도 이 클래스를 쓰게 될 것이다.
        val attributes = OAuthAttributes.of(
            registrationId,
            userNameAttributeName,
            oAuth2User.attributes
        )
		// 전달받은 OAuth2User의 attribute를 이용하여 회원가입 및 수정의 역할을 한다.
		// User Entity 생성 : 회원가입 
        // User Entity 수정 : update
        val user = saveOrUpdate(attributes) 
		// session에 SessionUser(user의 정보를 담는 객체)를 담아 저장한다.
        httpSession.setAttribute("user", SessionUser(user))
        return DefaultOAuth2User(
            setOf(SimpleGrantedAuthority(user.role.key)),
            attributes.attributes,
            attributes.nameAttributeKey
        )
    }
    fun saveOrUpdate(attributes: OAuthAttributes): User {
        val user = userRepository.findByEmail(attributes.email)
            ?.copy(name = attributes.name, picture = attributes.picture)
            ?: attributes.toEntity()
        return userRepository.save(user)
    }
}
## OAuthAttributes.kt
// 해당 form이 OAuth2에서 모두 공통적으로 사용할 수 있는 form 인 것 같다.
data class OAuthAttributes(
    val attributes: Map<String, Any>,
    val nameAttributeKey: String,
    val name: String,
    val email: String,
    val picture: String,
) {
    companion object {
        fun of(
            registrationId: String,
            userNameAttributeName: String,
            attributes: Map<String, Any>
        ): OAuthAttributes {
            return OAuthAttributes(
                name = attributes["name"] as String,
                email = attributes["email"] as String,
                picture = attributes["picture"] as String,
                attributes = attributes,
                nameAttributeKey = userNameAttributeName
            )
        }
    }
}
// User Entity 생성, 즉 회원가입에 사용될 것이다.
fun OAuthAttributes.toEntity(): User {
    return User(
        name = name,
        email = email,
        picture = picture,
        role = Role.USER
    )
}
## SessionUser.kt
data class SessionUser(
    private val user: User
): Serializable {
    val name = user.name
    val email = user.email
    val picture = user.picture
}
이후 로그인 테스트를 위해 html을 작성하자. 편의를 위해 mustache를 사용했다. 우선 gradle에 추가해주자.
## build.gradle.kts
implementation("org.springframework.boot:spring-boot-starter-mustache")
그 후 두 파일을 만든다.
## HtmlController.kt
@Controller
class HtmlController(
    private val httpSession: HttpSession
) {
    @GetMapping("/")
    fun home(model: Model): String {
        model.addAttribute("hi", "hi")
        val user = httpSession.getAttribute("user") as SessionUser?
        if (user != null) {
            model.addAttribute("user", user)
        }
        return "home"
    }
}
## resources/templates/home.mustache
<html>
<head></head>
<body>
    <div>
        <h1>{{hi}}</h1>
    </div>
    <div class="col-md-12">
        <div class="row">
            <div class="col-md-6">
                {{#user}}
                Logged in as: <span id="user">{{user.name}}</span>
                <img src={{user.picture}}>
                <a href="/logout" class="btn btn-info active" role="button">Logout</a>
                {{/user}}
                {{^user}}
              		// 해당 endpoint는 spring session에서 기본 제공한다고 한다.
                    <a href="/oauth2/authorization/google" class="btn btn-success acitve"
                       role="button">Google Login</a>
                {{/user}}
            </div>
        </div>
        <br>
        <!-- 목록 출력 영역 -->
    </div>
</body>
</html>
이렇게 될 경우 localhost:8080 으로 접속하면 index.mustache로 연결되게 되고, 그 과정에서 httpSession의 user가 있다면, 로그인 되어 있는 상태로 간주하고 해당 정보를 가지고 이름과 이미지를 그려주게 된다.




이렇게 성공적으로 OAuth2 Google 로그인을 구현해 낼 수 있다.
사실 이 정도로는 많이 부족하다. 아직 정확히 session을 통해 어떻게 유지되는지, OAuth2가 정확히 어떤식으로 동작하여 이 작업이 가능했던 것인지 등의 이슈들이 남아있다. 따라서 관련해야 추후 공부할 내용을 적어두고 틈틈히 채워넣는다.
구현 후 해당 web에서 F12를 눌러(크롬기준) 관리자 도구를 열어 살펴보면 JSession이 cookie로 들어있는 것을 볼 수 있다. 이를 찾아보니 Tomcat에서 session을 저장하는 방식이라고 한다. 이에 대한 추가 학습이 필요하다.
위 JSession과 연관이 있겠지만 OAuthService를 통한 로그인 동작이 정확히 어떤 프로세스로 이뤄지는지 깊게 파볼 이유가 있다. 이를 알아야 다른 social login도 추가하고, 효과적으로 로그인을 유지시킬 수 있을 것 같다.
아마 내가 짧은 지식으로 이해하기에 나는 OAuth2에서 아주 국소적인 부분만을 이용해서 오늘의 작업을 진행했다. 보면 google의 로그인을 하고 그 결과만을 가지고 User를 만들었을 뿐이다. OAuth2에서는 권한을 명시하는 access token을 부여하고, 이를 통해 해당 api 서버에 요청을 할 수 있게 된다~~ 라는 이야기도 많은데 이런 부분들은 전혀 다뤄보지 못한 것이다. 따라서 OAuth2에 대한 추가적인 깊은 이해와 연습이 필요해보인다.
https://blog.naver.com/writer0713/221587319282
jpa가 더 고도화된 class 이며 jpa 특화 기능을 쓰기 위해선 jpaRepository를, 단순 crud의 경우 crudrepository를 선택하며 된다. 여기서 JPA 특화 기능이 무엇이 있는지를 알아봐야 할 것 같다.
오늘로 User를 만들고 로그인을 구현하면서 Data를 쌓아나가고 하나의 어플리케이션 처럼 동작할 수 있는 큰 산을 하나 넘었다. 다만 아직 OAuth2에 대한 이해가 완전하지 않아 추가적으로 더 알아봐야할 것 같기는 하다. 그럼에도 이제 사실 지금의 정도만으로도 서버를 배포해 데이터를 쌓아나가기 시작해봐도 좋을 것 같다는 생각이 들었다. 물론 DB schema가 계속 바뀔 수는 있겠지만 말이다.
오늘의 작업 코드는 여기서 확인해 볼 수 있다.