2019-03-08 19:42:50 +03:00
// Copyright 2019 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package user
import (
2019-03-11 05:54:59 +03:00
"encoding/base64"
2019-03-08 19:42:50 +03:00
"fmt"
2020-08-28 07:37:05 +03:00
"html"
2021-04-05 18:30:52 +03:00
"net/http"
2019-03-08 19:42:50 +03:00
"net/url"
2019-03-11 05:54:59 +03:00
"strings"
2019-03-08 19:42:50 +03:00
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
2019-08-15 17:46:21 +03:00
"code.gitea.io/gitea/modules/timeutil"
2021-01-26 18:36:53 +03:00
"code.gitea.io/gitea/modules/web"
2021-04-06 22:44:05 +03:00
"code.gitea.io/gitea/services/forms"
2019-06-12 22:41:28 +03:00
2021-01-26 18:36:53 +03:00
"gitea.com/go-chi/binding"
2019-06-12 22:41:28 +03:00
"github.com/dgrijalva/jwt-go"
2019-03-08 19:42:50 +03:00
)
const (
tplGrantAccess base . TplName = "user/auth/grant"
tplGrantError base . TplName = "user/auth/grant_error"
)
// TODO move error and responses to SDK or models
// AuthorizeErrorCode represents an error code specified in RFC 6749
type AuthorizeErrorCode string
const (
// ErrorCodeInvalidRequest represents the according error in RFC 6749
ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request"
// ErrorCodeUnauthorizedClient represents the according error in RFC 6749
ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client"
// ErrorCodeAccessDenied represents the according error in RFC 6749
ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied"
// ErrorCodeUnsupportedResponseType represents the according error in RFC 6749
ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type"
// ErrorCodeInvalidScope represents the according error in RFC 6749
ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope"
// ErrorCodeServerError represents the according error in RFC 6749
ErrorCodeServerError AuthorizeErrorCode = "server_error"
// ErrorCodeTemporaryUnavailable represents the according error in RFC 6749
ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable"
)
// AuthorizeError represents an error type specified in RFC 6749
type AuthorizeError struct {
ErrorCode AuthorizeErrorCode ` json:"error" form:"error" `
ErrorDescription string
State string
}
// Error returns the error message
func ( err AuthorizeError ) Error ( ) string {
return fmt . Sprintf ( "%s: %s" , err . ErrorCode , err . ErrorDescription )
}
// AccessTokenErrorCode represents an error code specified in RFC 6749
type AccessTokenErrorCode string
const (
// AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749
AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request"
// AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749
AccessTokenErrorCodeInvalidClient = "invalid_client"
// AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749
AccessTokenErrorCodeInvalidGrant = "invalid_grant"
// AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749
AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client"
// AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749
AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type"
// AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749
AccessTokenErrorCodeInvalidScope = "invalid_scope"
)
// AccessTokenError represents an error response specified in RFC 6749
type AccessTokenError struct {
ErrorCode AccessTokenErrorCode ` json:"error" form:"error" `
ErrorDescription string ` json:"error_description" `
}
// Error returns the error message
func ( err AccessTokenError ) Error ( ) string {
return fmt . Sprintf ( "%s: %s" , err . ErrorCode , err . ErrorDescription )
}
// TokenType specifies the kind of token
type TokenType string
const (
// TokenTypeBearer represents a token type specified in RFC 6749
TokenTypeBearer TokenType = "bearer"
// TokenTypeMAC represents a token type specified in RFC 6749
TokenTypeMAC = "mac"
)
// AccessTokenResponse represents a successful access token response
type AccessTokenResponse struct {
2019-04-12 10:50:21 +03:00
AccessToken string ` json:"access_token" `
TokenType TokenType ` json:"token_type" `
ExpiresIn int64 ` json:"expires_in" `
RefreshToken string ` json:"refresh_token" `
2021-01-01 19:33:27 +03:00
IDToken string ` json:"id_token,omitempty" `
2019-03-08 19:42:50 +03:00
}
2021-01-01 19:33:27 +03:00
func newAccessTokenResponse ( grant * models . OAuth2Grant , clientSecret string ) ( * AccessTokenResponse , * AccessTokenError ) {
2019-04-12 10:50:21 +03:00
if setting . OAuth2 . InvalidateRefreshTokens {
if err := grant . IncreaseCounter ( ) ; err != nil {
return nil , & AccessTokenError {
ErrorCode : AccessTokenErrorCodeInvalidGrant ,
ErrorDescription : "cannot increase the grant counter" ,
}
2019-03-08 19:42:50 +03:00
}
}
// generate access token to access the API
2019-08-15 17:46:21 +03:00
expirationDate := timeutil . TimeStampNow ( ) . Add ( setting . OAuth2 . AccessTokenExpirationTime )
2019-03-08 19:42:50 +03:00
accessToken := & models . OAuth2Token {
GrantID : grant . ID ,
Type : models . TypeAccessToken ,
StandardClaims : jwt . StandardClaims {
ExpiresAt : expirationDate . AsTime ( ) . Unix ( ) ,
} ,
}
signedAccessToken , err := accessToken . SignToken ( )
if err != nil {
return nil , & AccessTokenError {
ErrorCode : AccessTokenErrorCodeInvalidRequest ,
ErrorDescription : "cannot sign token" ,
}
}
// generate refresh token to request an access token after it expired later
2019-08-15 17:46:21 +03:00
refreshExpirationDate := timeutil . TimeStampNow ( ) . Add ( setting . OAuth2 . RefreshTokenExpirationTime * 60 * 60 ) . AsTime ( ) . Unix ( )
2019-03-08 19:42:50 +03:00
refreshToken := & models . OAuth2Token {
GrantID : grant . ID ,
Counter : grant . Counter ,
Type : models . TypeRefreshToken ,
StandardClaims : jwt . StandardClaims {
ExpiresAt : refreshExpirationDate ,
} ,
}
signedRefreshToken , err := refreshToken . SignToken ( )
if err != nil {
return nil , & AccessTokenError {
ErrorCode : AccessTokenErrorCodeInvalidRequest ,
ErrorDescription : "cannot sign token" ,
}
}
2021-01-01 19:33:27 +03:00
// generate OpenID Connect id_token
signedIDToken := ""
if grant . ScopeContains ( "openid" ) {
app , err := models . GetOAuth2ApplicationByID ( grant . ApplicationID )
if err != nil {
return nil , & AccessTokenError {
ErrorCode : AccessTokenErrorCodeInvalidRequest ,
ErrorDescription : "cannot find application" ,
}
}
idToken := & models . OIDCToken {
StandardClaims : jwt . StandardClaims {
ExpiresAt : expirationDate . AsTime ( ) . Unix ( ) ,
Issuer : setting . AppURL ,
Audience : app . ClientID ,
Subject : fmt . Sprint ( grant . UserID ) ,
} ,
Nonce : grant . Nonce ,
}
signedIDToken , err = idToken . SignToken ( clientSecret )
if err != nil {
return nil , & AccessTokenError {
ErrorCode : AccessTokenErrorCodeInvalidRequest ,
ErrorDescription : "cannot sign token" ,
}
}
}
2019-03-08 19:42:50 +03:00
return & AccessTokenResponse {
AccessToken : signedAccessToken ,
TokenType : TokenTypeBearer ,
ExpiresIn : setting . OAuth2 . AccessTokenExpirationTime ,
RefreshToken : signedRefreshToken ,
2021-01-01 19:33:27 +03:00
IDToken : signedIDToken ,
2019-03-08 19:42:50 +03:00
} , nil
}
// AuthorizeOAuth manages authorize requests
2021-01-26 18:36:53 +03:00
func AuthorizeOAuth ( ctx * context . Context ) {
2021-04-06 22:44:05 +03:00
form := web . GetForm ( ctx ) . ( * forms . AuthorizationForm )
2019-03-08 19:42:50 +03:00
errs := binding . Errors { }
2021-01-26 18:36:53 +03:00
errs = form . Validate ( ctx . Req , errs )
2019-06-12 22:41:28 +03:00
if len ( errs ) > 0 {
errstring := ""
for _ , e := range errs {
errstring += e . Error ( ) + "\n"
}
2019-06-13 07:23:45 +03:00
ctx . ServerError ( "AuthorizeOAuth: Validate: " , fmt . Errorf ( "errors occurred during validation: %s" , errstring ) )
2019-06-12 22:41:28 +03:00
return
}
2019-03-08 19:42:50 +03:00
app , err := models . GetOAuth2ApplicationByClientID ( form . ClientID )
if err != nil {
if models . IsErrOauthClientIDInvalid ( err ) {
handleAuthorizeError ( ctx , AuthorizeError {
ErrorCode : ErrorCodeUnauthorizedClient ,
ErrorDescription : "Client ID not registered" ,
State : form . State ,
} , "" )
return
}
ctx . ServerError ( "GetOAuth2ApplicationByClientID" , err )
return
}
if err := app . LoadUser ( ) ; err != nil {
ctx . ServerError ( "LoadUser" , err )
return
}
if ! app . ContainsRedirectURI ( form . RedirectURI ) {
handleAuthorizeError ( ctx , AuthorizeError {
ErrorCode : ErrorCodeInvalidRequest ,
ErrorDescription : "Unregistered Redirect URI" ,
State : form . State ,
} , "" )
return
}
if form . ResponseType != "code" {
handleAuthorizeError ( ctx , AuthorizeError {
ErrorCode : ErrorCodeUnsupportedResponseType ,
ErrorDescription : "Only code response type is supported." ,
State : form . State ,
} , form . RedirectURI )
return
}
// pkce support
switch form . CodeChallengeMethod {
case "S256" :
case "plain" :
if err := ctx . Session . Set ( "CodeChallengeMethod" , form . CodeChallengeMethod ) ; err != nil {
handleAuthorizeError ( ctx , AuthorizeError {
ErrorCode : ErrorCodeServerError ,
ErrorDescription : "cannot set code challenge method" ,
State : form . State ,
} , form . RedirectURI )
return
}
if err := ctx . Session . Set ( "CodeChallengeMethod" , form . CodeChallenge ) ; err != nil {
handleAuthorizeError ( ctx , AuthorizeError {
ErrorCode : ErrorCodeServerError ,
ErrorDescription : "cannot set code challenge" ,
State : form . State ,
} , form . RedirectURI )
return
}
2020-05-17 15:43:29 +03:00
// Here we're just going to try to release the session early
if err := ctx . Session . Release ( ) ; err != nil {
// we'll tolerate errors here as they *should* get saved elsewhere
log . Error ( "Unable to save changes to the session: %v" , err )
}
2019-03-08 19:42:50 +03:00
case "" :
break
default :
handleAuthorizeError ( ctx , AuthorizeError {
ErrorCode : ErrorCodeInvalidRequest ,
ErrorDescription : "unsupported code challenge method" ,
State : form . State ,
} , form . RedirectURI )
return
}
grant , err := app . GetGrantByUserID ( ctx . User . ID )
if err != nil {
handleServerError ( ctx , form . State , form . RedirectURI )
return
}
// Redirect if user already granted access
if grant != nil {
code , err := grant . GenerateNewAuthorizationCode ( form . RedirectURI , form . CodeChallenge , form . CodeChallengeMethod )
if err != nil {
handleServerError ( ctx , form . State , form . RedirectURI )
return
}
redirect , err := code . GenerateRedirectURI ( form . State )
if err != nil {
handleServerError ( ctx , form . State , form . RedirectURI )
return
}
2021-01-01 19:33:27 +03:00
// Update nonce to reflect the new session
if len ( form . Nonce ) > 0 {
err := grant . SetNonce ( form . Nonce )
if err != nil {
log . Error ( "Unable to update nonce: %v" , err )
}
}
2019-03-08 19:42:50 +03:00
ctx . Redirect ( redirect . String ( ) , 302 )
return
}
// show authorize page to grant access
ctx . Data [ "Application" ] = app
ctx . Data [ "RedirectURI" ] = form . RedirectURI
ctx . Data [ "State" ] = form . State
2021-01-01 19:33:27 +03:00
ctx . Data [ "Scope" ] = form . Scope
ctx . Data [ "Nonce" ] = form . Nonce
2020-08-28 07:37:05 +03:00
ctx . Data [ "ApplicationUserLink" ] = "<a href=\"" + html . EscapeString ( setting . AppURL ) + html . EscapeString ( url . PathEscape ( app . User . LowerName ) ) + "\">@" + html . EscapeString ( app . User . Name ) + "</a>"
ctx . Data [ "ApplicationRedirectDomainHTML" ] = "<strong>" + html . EscapeString ( form . RedirectURI ) + "</strong>"
2019-03-08 19:42:50 +03:00
// TODO document SESSION <=> FORM
2019-06-12 22:41:28 +03:00
err = ctx . Session . Set ( "client_id" , app . ClientID )
if err != nil {
handleServerError ( ctx , form . State , form . RedirectURI )
log . Error ( err . Error ( ) )
return
}
err = ctx . Session . Set ( "redirect_uri" , form . RedirectURI )
if err != nil {
handleServerError ( ctx , form . State , form . RedirectURI )
log . Error ( err . Error ( ) )
return
}
err = ctx . Session . Set ( "state" , form . State )
if err != nil {
handleServerError ( ctx , form . State , form . RedirectURI )
log . Error ( err . Error ( ) )
return
}
2020-05-17 15:43:29 +03:00
// Here we're just going to try to release the session early
if err := ctx . Session . Release ( ) ; err != nil {
// we'll tolerate errors here as they *should* get saved elsewhere
log . Error ( "Unable to save changes to the session: %v" , err )
}
2021-04-05 18:30:52 +03:00
ctx . HTML ( http . StatusOK , tplGrantAccess )
2019-03-08 19:42:50 +03:00
}
// GrantApplicationOAuth manages the post request submitted when a user grants access to an application
2021-01-26 18:36:53 +03:00
func GrantApplicationOAuth ( ctx * context . Context ) {
2021-04-06 22:44:05 +03:00
form := web . GetForm ( ctx ) . ( * forms . GrantApplicationForm )
2019-03-08 19:42:50 +03:00
if ctx . Session . Get ( "client_id" ) != form . ClientID || ctx . Session . Get ( "state" ) != form . State ||
ctx . Session . Get ( "redirect_uri" ) != form . RedirectURI {
2021-04-05 18:30:52 +03:00
ctx . Error ( http . StatusBadRequest )
2019-03-08 19:42:50 +03:00
return
}
app , err := models . GetOAuth2ApplicationByClientID ( form . ClientID )
if err != nil {
ctx . ServerError ( "GetOAuth2ApplicationByClientID" , err )
return
}
2021-01-01 19:33:27 +03:00
grant , err := app . CreateGrant ( ctx . User . ID , form . Scope )
2019-03-08 19:42:50 +03:00
if err != nil {
handleAuthorizeError ( ctx , AuthorizeError {
State : form . State ,
ErrorDescription : "cannot create grant for user" ,
ErrorCode : ErrorCodeServerError ,
} , form . RedirectURI )
return
}
2021-01-01 19:33:27 +03:00
if len ( form . Nonce ) > 0 {
err := grant . SetNonce ( form . Nonce )
if err != nil {
log . Error ( "Unable to update nonce: %v" , err )
}
}
2019-03-08 19:42:50 +03:00
var codeChallenge , codeChallengeMethod string
codeChallenge , _ = ctx . Session . Get ( "CodeChallenge" ) . ( string )
codeChallengeMethod , _ = ctx . Session . Get ( "CodeChallengeMethod" ) . ( string )
code , err := grant . GenerateNewAuthorizationCode ( form . RedirectURI , codeChallenge , codeChallengeMethod )
if err != nil {
handleServerError ( ctx , form . State , form . RedirectURI )
return
}
redirect , err := code . GenerateRedirectURI ( form . State )
if err != nil {
handleServerError ( ctx , form . State , form . RedirectURI )
2019-04-25 14:30:38 +03:00
return
2019-03-08 19:42:50 +03:00
}
ctx . Redirect ( redirect . String ( ) , 302 )
}
// AccessTokenOAuth manages all access token requests by the client
2021-01-26 18:36:53 +03:00
func AccessTokenOAuth ( ctx * context . Context ) {
2021-04-06 22:44:05 +03:00
form := * web . GetForm ( ctx ) . ( * forms . AccessTokenForm )
2019-03-11 05:54:59 +03:00
if form . ClientID == "" {
authHeader := ctx . Req . Header . Get ( "Authorization" )
authContent := strings . SplitN ( authHeader , " " , 2 )
if len ( authContent ) == 2 && authContent [ 0 ] == "Basic" {
payload , err := base64 . StdEncoding . DecodeString ( authContent [ 1 ] )
if err != nil {
handleAccessTokenError ( ctx , AccessTokenError {
ErrorCode : AccessTokenErrorCodeInvalidRequest ,
ErrorDescription : "cannot parse basic auth header" ,
} )
return
}
pair := strings . SplitN ( string ( payload ) , ":" , 2 )
if len ( pair ) != 2 {
handleAccessTokenError ( ctx , AccessTokenError {
ErrorCode : AccessTokenErrorCodeInvalidRequest ,
ErrorDescription : "cannot parse basic auth header" ,
} )
return
}
form . ClientID = pair [ 0 ]
form . ClientSecret = pair [ 1 ]
}
}
2019-03-08 19:42:50 +03:00
switch form . GrantType {
case "refresh_token" :
handleRefreshToken ( ctx , form )
return
case "authorization_code" :
handleAuthorizationCode ( ctx , form )
return
default :
handleAccessTokenError ( ctx , AccessTokenError {
ErrorCode : AccessTokenErrorCodeUnsupportedGrantType ,
ErrorDescription : "Only refresh_token or authorization_code grant type is supported" ,
} )
}
}
2021-04-06 22:44:05 +03:00
func handleRefreshToken ( ctx * context . Context , form forms . AccessTokenForm ) {
2019-03-08 19:42:50 +03:00
token , err := models . ParseOAuth2Token ( form . RefreshToken )
if err != nil {
handleAccessTokenError ( ctx , AccessTokenError {
ErrorCode : AccessTokenErrorCodeUnauthorizedClient ,
ErrorDescription : "client is not authorized" ,
} )
return
}
// get grant before increasing counter
grant , err := models . GetOAuth2GrantByID ( token . GrantID )
if err != nil || grant == nil {
handleAccessTokenError ( ctx , AccessTokenError {
ErrorCode : AccessTokenErrorCodeInvalidGrant ,
ErrorDescription : "grant does not exist" ,
} )
return
}
// check if token got already used
2019-04-12 10:50:21 +03:00
if setting . OAuth2 . InvalidateRefreshTokens && ( grant . Counter != token . Counter || token . Counter == 0 ) {
2019-03-08 19:42:50 +03:00
handleAccessTokenError ( ctx , AccessTokenError {
ErrorCode : AccessTokenErrorCodeUnauthorizedClient ,
ErrorDescription : "token was already used" ,
} )
log . Warn ( "A client tried to use a refresh token for grant_id = %d was used twice!" , grant . ID )
return
}
2021-01-01 19:33:27 +03:00
accessToken , tokenErr := newAccessTokenResponse ( grant , form . ClientSecret )
2019-03-08 19:42:50 +03:00
if tokenErr != nil {
handleAccessTokenError ( ctx , * tokenErr )
return
}
2021-04-05 18:30:52 +03:00
ctx . JSON ( http . StatusOK , accessToken )
2019-03-08 19:42:50 +03:00
}
2021-04-06 22:44:05 +03:00
func handleAuthorizationCode ( ctx * context . Context , form forms . AccessTokenForm ) {
2019-03-08 19:42:50 +03:00
app , err := models . GetOAuth2ApplicationByClientID ( form . ClientID )
if err != nil {
handleAccessTokenError ( ctx , AccessTokenError {
ErrorCode : AccessTokenErrorCodeInvalidClient ,
2019-03-11 05:54:59 +03:00
ErrorDescription : fmt . Sprintf ( "cannot load client with client id: '%s'" , form . ClientID ) ,
2019-03-08 19:42:50 +03:00
} )
return
}
if ! app . ValidateClientSecret ( [ ] byte ( form . ClientSecret ) ) {
handleAccessTokenError ( ctx , AccessTokenError {
ErrorCode : AccessTokenErrorCodeUnauthorizedClient ,
ErrorDescription : "client is not authorized" ,
} )
return
}
if form . RedirectURI != "" && ! app . ContainsRedirectURI ( form . RedirectURI ) {
handleAccessTokenError ( ctx , AccessTokenError {
ErrorCode : AccessTokenErrorCodeUnauthorizedClient ,
ErrorDescription : "client is not authorized" ,
} )
return
}
authorizationCode , err := models . GetOAuth2AuthorizationByCode ( form . Code )
if err != nil || authorizationCode == nil {
handleAccessTokenError ( ctx , AccessTokenError {
ErrorCode : AccessTokenErrorCodeUnauthorizedClient ,
ErrorDescription : "client is not authorized" ,
} )
return
}
// check if code verifier authorizes the client, PKCE support
if ! authorizationCode . ValidateCodeChallenge ( form . CodeVerifier ) {
handleAccessTokenError ( ctx , AccessTokenError {
ErrorCode : AccessTokenErrorCodeUnauthorizedClient ,
ErrorDescription : "client is not authorized" ,
} )
return
}
// check if granted for this application
if authorizationCode . Grant . ApplicationID != app . ID {
handleAccessTokenError ( ctx , AccessTokenError {
ErrorCode : AccessTokenErrorCodeInvalidGrant ,
ErrorDescription : "invalid grant" ,
} )
return
}
// remove token from database to deny duplicate usage
if err := authorizationCode . Invalidate ( ) ; err != nil {
handleAccessTokenError ( ctx , AccessTokenError {
ErrorCode : AccessTokenErrorCodeInvalidRequest ,
ErrorDescription : "cannot proceed your request" ,
} )
}
2021-01-01 19:33:27 +03:00
resp , tokenErr := newAccessTokenResponse ( authorizationCode . Grant , form . ClientSecret )
2019-03-08 19:42:50 +03:00
if tokenErr != nil {
handleAccessTokenError ( ctx , * tokenErr )
return
}
// send successful response
2021-04-05 18:30:52 +03:00
ctx . JSON ( http . StatusOK , resp )
2019-03-08 19:42:50 +03:00
}
func handleAccessTokenError ( ctx * context . Context , acErr AccessTokenError ) {
2021-04-05 18:30:52 +03:00
ctx . JSON ( http . StatusBadRequest , acErr )
2019-03-08 19:42:50 +03:00
}
func handleServerError ( ctx * context . Context , state string , redirectURI string ) {
handleAuthorizeError ( ctx , AuthorizeError {
ErrorCode : ErrorCodeServerError ,
ErrorDescription : "A server error occurred" ,
State : state ,
} , redirectURI )
}
func handleAuthorizeError ( ctx * context . Context , authErr AuthorizeError , redirectURI string ) {
if redirectURI == "" {
log . Warn ( "Authorization failed: %v" , authErr . ErrorDescription )
ctx . Data [ "Error" ] = authErr
ctx . HTML ( 400 , tplGrantError )
return
}
redirect , err := url . Parse ( redirectURI )
if err != nil {
ctx . ServerError ( "url.Parse" , err )
return
}
q := redirect . Query ( )
q . Set ( "error" , string ( authErr . ErrorCode ) )
q . Set ( "error_description" , authErr . ErrorDescription )
q . Set ( "state" , authErr . State )
redirect . RawQuery = q . Encode ( )
ctx . Redirect ( redirect . String ( ) , 302 )
}