plugin-rest/spring-security-rest/grails-app/controllers/grails/plugin/springsecurity/rest/RestOauthController.groovy (130 lines of code) (raw):

/* Copyright 2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package grails.plugin.springsecurity.rest import com.nimbusds.jwt.JWT import grails.core.GrailsApplication import grails.plugin.springsecurity.annotation.Secured import grails.plugin.springsecurity.rest.authentication.RestAuthenticationEventPublisher import grails.plugin.springsecurity.rest.error.CallbackErrorHandler import grails.plugin.springsecurity.rest.token.AccessToken import grails.plugin.springsecurity.rest.token.generation.jwt.AbstractJwtTokenGenerator import grails.plugin.springsecurity.rest.token.rendering.AccessTokenJsonRenderer import grails.plugin.springsecurity.rest.token.storage.TokenStorageService import groovy.util.logging.Slf4j import org.apache.commons.codec.binary.Base64 import org.grails.plugins.codecs.URLCodec import org.pac4j.core.client.IndirectClient import org.pac4j.core.context.CallContext import org.pac4j.core.context.WebContext import org.pac4j.core.exception.http.RedirectionAction import org.pac4j.jee.context.JEEContext import org.pac4j.jee.context.session.JEESessionStore import org.springframework.http.HttpStatus import org.springframework.security.core.userdetails.User import java.nio.charset.StandardCharsets @Slf4j @Secured(['permitAll']) class RestOauthController { static allowedMethods = [accessToken: 'POST'] final String CALLBACK_ATTR = "spring-security-rest-callback" CallbackErrorHandler callbackErrorHandler RestOauthService restOauthService GrailsApplication grailsApplication JwtService jwtService TokenStorageService tokenStorageService def tokenGenerator AccessTokenJsonRenderer accessTokenJsonRenderer RestAuthenticationEventPublisher authenticationEventPublisher /** * Starts the OAuth authentication flow, redirecting to the provider's Login URL. An optional callback parameter * allows the frontend application to define the frontend callback URL on demand. */ def authenticate(String provider, String callback) { IndirectClient client = restOauthService.getClient(provider) WebContext context = new JEEContext(request, response) if (callback) { try { if (Base64.isBase64(callback.getBytes())){ callback = new String(callback.decodeBase64(), StandardCharsets.UTF_8) } log.debug "Trying to store in the HTTP session a user specified callback URL: ${callback}" session[CALLBACK_ATTR] = new URL(callback).toString() } catch (MalformedURLException mue) { log.warn "The URL is malformed, is it base64 encoded? Not storing it." } } RedirectionAction redirectAction = client.getRedirectionAction(context, JEESessionStore.INSTANCE).get() log.debug "Redirecting to ${redirectAction.location}" redirect url: redirectAction.location } /** * Handles the OAuth provider callback. It uses {@link RestOauthService} to generate and store a token for that user, * and finally redirects to the configured frontend callback URL, where the token is in the URL. That way, the * frontend application can store the REST API token locally for subsequent API calls. */ def callback(String provider) { CallContext context = new CallContext(new JEEContext(request, response), null) def frontendCallbackUrl if (session[CALLBACK_ATTR]) { log.debug "Found callback URL in the HTTP session" frontendCallbackUrl = session[CALLBACK_ATTR] } else { log.debug "Found callback URL in the configuration file" frontendCallbackUrl = grailsApplication.config['grails.plugin.springsecurity.rest.oauth.frontendCallbackUrl'] } try { String tokenValue = restOauthService.storeAuthentication(provider, context) frontendCallbackUrl = getCallbackUrl(frontendCallbackUrl, tokenValue) } catch (Exception e) { def errorParams = new StringBuilder() Map params = callbackErrorHandler.convert(e) URLCodec urlCodec = new URLCodec() params.each { key, value -> errorParams << "&${key}=${urlCodec.encoder.encode(value)}" } frontendCallbackUrl = getCallbackUrl(frontendCallbackUrl, errorParams.toString()) } log.debug "Redirecting to ${frontendCallbackUrl}" redirect url: frontendCallbackUrl } private String getCallbackUrl(baseUrl, String queryStringSuffix) { session[CALLBACK_ATTR] = null baseUrl instanceof Closure ? baseUrl(queryStringSuffix) : baseUrl + queryStringSuffix } /** * Generates a new access token given the refresh token passed */ def accessToken() { String grantType = params['grant_type'] if (!grantType || grantType != 'refresh_token') { render status: HttpStatus.BAD_REQUEST, text: "Invalid grant_type" return } String refreshToken = params['refresh_token'] log.debug "Trying to generate an access token for the refresh token: ${refreshToken}" if(!refreshToken) { log.debug "Refresh token is missing. Replying with bad request" render status: HttpStatus.BAD_REQUEST, text: "Refresh token is required" return } // only JWT tokens can be refreshed if(!AbstractJwtTokenGenerator.isAssignableFrom(tokenGenerator.getClass())) { log.debug("Token type does not support refresh tokens") render status: HttpStatus.FORBIDDEN return } try { JWT jwt = jwtService.parse(refreshToken) if(!jwt || !jwt.JWTClaimsSet.getBooleanClaim(AbstractJwtTokenGenerator.REFRESH_ONLY_CLAIM)) { log.debug("Token ${refreshToken} is not a refresh token") render status: HttpStatus.FORBIDDEN return } } catch(e) { log.debug("Invalid refresh token: ${refreshToken}", e) render status: HttpStatus.FORBIDDEN return } try { def user = tokenStorageService.loadUserByToken(refreshToken) User principal = user ? user as User : null log.debug "Principal found for refresh token: ${principal}" AccessToken accessToken = (tokenGenerator as AbstractJwtTokenGenerator).generateAccessToken(principal, false) accessToken.refreshToken = refreshToken tokenStorageService.storeToken(accessToken) authenticationEventPublisher.publishTokenCreation(accessToken) response.addHeader 'Cache-Control', 'no-store' response.addHeader 'Pragma', 'no-cache' render contentType: 'application/json', encoding: 'UTF-8', text: accessTokenJsonRenderer.generateJson(accessToken) } catch (e) { log.debug("Could not load by refresh token", e) render status: HttpStatus.FORBIDDEN } } }