I've built a web service that supports login and registration natively through forms on the site. Now, I'd like to supplement that by allowing users to login/signup using a Facebook account. I managed to get the OAuth Login flow for Facebook working, but now I'm trying to figure out the "registration" part of this flow. I know I can add a custom success handler to persist the user information to the database. As a hacky example:
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.formLogin {
it.loginPage("/login")
it.usernameParameter("email")
it.passwordParameter("password")
}
.oauth2Login {
it.loginPage("/login")
it.clientRegistrationRepository(clientRegistrationRepository())
it.successHandler { _, _, authentication ->
if (authentication.isAuthenticated) {
userRepository.save(...) // persist registration details
}
}
}
However, I have a few questions about this approach and ensuring I'm doing this in idiomatic "springboot" way.
What's the best strategy for handling roles? For users who register directly through the site I assign a default ROLE_USER
, but admins may also grant additional roles like: ROLE_EDITOR
, etc. When logging in through Facebook, spring is just assigning the authority OAUTH2_USER
to the user. I'd like to augment or replace that with the roles the user has in the database. Is it as simple as adding logic in the successHandler to fetch the user info from the datastore and add the roles in the datastore to the principal object?
How should I handle generating links on the website so the user can view their profile? Currently I do that like so:
<a sec:authorize="hasRole('USER')" th:href="@{/profile/{id}(id=${#authentication.getPrincipal().id})}">View Profile</a>
However, the OAuth2User does not have an ID. It doesn't seem to be a value I can set either, unlike the user roles. Ideally, I'd like to avoid custom logic in the views and elsewhere to determine if the user is authenticated via OAuth or not.
Okay here's what I ended up doing.
OAuth2UserService
interfaceOAuthUserService
class OAuth2EmailExistsException(override val message: String): AuthenticationException(message)
@Service
class FacebookOAuth2UserService(
private val userRepository: UserRepository,
private val clockService: ClockService,
private val idService: IdService,
private val defaultOAuth2UserService: DefaultOAuth2UserService
): OAuth2UserService<OAuth2UserRequest, OAuth2User> {
override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User {
val oauthUser = defaultOAuth2UserService.loadUser(userRequest)
val id = oauthUser.name
val email = oauthUser.attributes["email"] as String
val name = oauthUser.attributes["name"] as String
val persistedUser = userRepository.findByoAuthId(id).getOrElse {
if (userRepository.existsByEmail(email)) {
throw OAuth2EmailExistsException("The email associated with this Facebook account is already present in the system")
}
userRepository.save(User(
id = idService.objectId(),
firstName = name,
lastName = "",
password = "",
email = email,
status = UserStatus.ACTIVE,
roles = setOf(SimpleGrantedAuthority(AuthRoles.USER.roleName())),
joinDate = clockService.now().toEpochMilli(),
timeOfPreviousNameUpdate = 0,
oAuthId = id,
source = RegistrationSource.FACEBOOK
))
}
return FacebookOAuth2User(persistedUser, oauthUser.attributes)
}
}
New OAuth2 User
class FacebookOAuth2User(
private val user: User,
private val attributes: MutableMap<String, Any>
): OAuth2User, AuthUserDetails {
val id = user.id
override fun getUserId(): String = user.id
// Facebook serves the name as a single entity, so we'll just store it in the
// first name column
override fun getName(): String = user.firstName
override fun getAttributes(): MutableMap<String, Any> = attributes
override fun getAuthorities(): Set<GrantedAuthority> = user.authorities
override fun getPassword(): String = user.password
override fun getUsername(): String = user.oAuthId!!
override fun isAccountNonExpired() = user.isAccountNonLocked
override fun isAccountNonLocked() = user.isAccountNonLocked
override fun isCredentialsNonExpired() = user.isCredentialsNonExpired
override fun isEnabled() = user.isEnabled
}
OAuth2Configuration
@Configuration
class OAuth2Configuration() {
@Bean
fun defaultOAuth2UserService(): DefaultOAuth2UserService = DefaultOAuth2UserService()
}
Security Configuration
@Configuration
class SecurityConfiguration(
private val facebookOAuth2UserService: FacebookOAuth2UserService,
private val environment: Environment
) {
fun clientRegistrationRepository(): ClientRegistrationRepository {
return InMemoryClientRegistrationRepository(
CommonOAuth2Provider.FACEBOOK.getBuilder("facebook")
.clientId(environment.getRequiredProperty("FACEBOOK_APP_KEY"))
.clientSecret(environment.getRequiredProperty("FACEBOOK_APP_SECRET"))
.build()
)
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.formLogin {
it.loginPage("/login")
it.usernameParameter("email")
it.passwordParameter("password")
}
.oauth2Login {
it.loginPage("/login")
it.clientRegistrationRepository(clientRegistrationRepository())
it.userInfoEndpoint {
it.userService(facebookOAuth2UserService)
}
it.failureHandler { _, response, exception ->
val errorParam = when (exception) {
is OAuth2EmailExistsException -> "oauthEmailExists"
else -> "oauthError"
}
response.sendRedirect("/login?$errorParam")
}
}
...