diff --git a/CHANGELOG.md b/CHANGELOG.md index 58216b1f..78140ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO. +24.1.0 (08-09-2024) +=================== + +* Updated 2FA secretparsing to support osf.io python 3.12 upgrade +* Added OAuth guide +* Fixed typo in Institution SSO guide + 23.2.1 (07-07-2023) =================== diff --git a/README_INSTITUTION_SSO_INTEGRATION_GUIDE.md b/README_INSTITUTION_SSO_INTEGRATION_GUIDE.md index be2a7597..0331428e 100644 --- a/README_INSTITUTION_SSO_INTEGRATION_GUIDE.md +++ b/README_INSTITUTION_SSO_INTEGRATION_GUIDE.md @@ -52,7 +52,7 @@ For institutions that are not registered with any participant of eduGAIN, COS of * Required Attribute * Unique identifier for the user * User's institutional email - * Either of the follwoing two + * Either of the following two * User's full name * User's first **AND** last name * Attribute name and format @@ -68,7 +68,7 @@ For institutions that are not registered with any participant of eduGAIN, COS of * Provide COS with the metadata URL for your IdP server. -* Provide COS with the entiry ID for your IdP server, which should be the same as the one defined in your metadata. +* Provide COS with the entity ID for your IdP server, which should be the same as the one defined in your metadata. * It is recommended that a temporary institution test account is created for COS engineers if possible, which will significantly aid and accelerate the process. diff --git a/README_OAUTH_SSO_GUIDE.md b/README_OAUTH_SSO_GUIDE.md new file mode 100644 index 00000000..596c0571 --- /dev/null +++ b/README_OAUTH_SSO_GUIDE.md @@ -0,0 +1,286 @@ +# OSF CAS as an OAuth Server + +## About + +OSF CAS serves as an OAuth 2.0 authorization server for OSF in addition to its primary role as a CAS authentication server. + +### The OAuth 2.0 Protocol + +* [RFC 6749](https://tools.ietf.org/html/rfc6749) +* [OAuth 2.0](https://oauth.net/2/) +* [OAuth 2.0 Simplified](https://aaronparecki.com/oauth-2-simplified/) + +### Parties + +| Party | Who | +|----------------------|-------------------| +| Client Application | A web application | +| Authorization Server | OSF CAS | +| Resource Owner | OSF Users | +| Resource Server | OSF API | + +## Features + +### General + +* Authorize client applications +* Exchange authorization code for access and refresh token +* Refresh access token using refresh token +* Revoke access and refresh tokens + +### Client Application Owners + +* Revoke all tokens of the application for all users + +### Resource Owners + +* List all authorized applications +* Revoke all tokens of an authorized application for the owner + +
+ +## Endpoints + +### `GET /oauth2/authorize` + +#### Authorize a Client Application + +Securely allows or denies the client application's request to access the resource user's information with specified scopes. It returns a one-time and short-lived authorization code, which will be used to follow up with the token-code exchange. + +* Authorization Web Flow + * (1) The client application issues the initial authorization request to `oauth2/authorize` with the above parameters; + * (2) If the user is not logged in, redirects to the primary CAS login with `oauth2/callbackAuthorize` as service; + * (3) Both step 1 and 2 end up redirecting the user to `oauth2/callbackAuthorize` for service validation; + * (4) After validation, redirects to `oauth2/callbackAuthorize` one more time, which checks previous decisions and asks the user to allow or deny the authorization if necessary. + * (5) If denied, redirects to `/oauth2/callbackAuthorizeAction?action=DENY`; if allowed, redirects to `/oauth2/callbackAuthorizeAction?action=ALLOW` + * (6) Finally, for both decisions in step 5, redirects the user to the *Redirect URI*: `https://my.app.io/oauth2/callback?` with different query parameters as shown below. + +* Request in Step (1) + +``` +https://accounts.osf.io/oauth2/authorize +``` + +| Parameter | Value / Example | Notes | +|-----------------|-------------------------------------|--------------------------------| +| response_type | code | | +| client_id | `ffe5247b810045a8a9277d3b3b4edc7a` | | +| redirect_uri | `https://my.app.io/oauth2/callback` | | +| scope | `osf.full_read` | | +| access_type | **`online`** / `offline` | Optional with default `online` | +| approval_prompt | **`auto`** / `force` | Optional with default `auto` | + +* Response in Step (6) + +| Parameter | Value / Example | Note | +|-----------|-----------------------------------------------------------|------------------------| +| code | `AC-1-mFs7MrWvaQy1fiidWGwXTw4dbAH30wk39cAELJnxizjGCUXYJl` | If `ALLOW` in step (5) | + +| Parameter | Value / Example | Note | +|-----------|-----------------|-----------------------| +| error | `access_denied` | If `DENY` in step (5) | + +

+ +### `POST /oauth2/token` + +#### Exchange Code for Token + +Exchanges the authorization code for an access token and potentially a refresh token if `offline` mode was specified **when requesting for the authorization code**. + +* Request + +``` +https://accounts.osf.io/oauth2/token +``` + +* `POST` Body Parameters + +| Parameter | Value / Example | Notes | +|---------------|-----------------------------------------------------------|-------| +| code | `AC-1-mFs7MrWvaQy1fiidWGwXTw4dbAH30wk39cAELJnxizjGCUXYJl` | | +| client_id | `ffe5247b810045a8a9277d3b3b4edc7a` | | +| client_secret | `5PgE96R3Z53dBuwBDkJfbK6ItDXvGhaxYpQ6r4cU` | | +| redirect_uri | `https://my.app.io/oauth2/callback` | | +| grant_type | `authorization_code` | | + +* Response Status + +``` +HTTP 200 OK +``` +* Response Body if `online` Mode + +```json +{ + "access_token": "AT-2-p5jtVLATgft5EHqqbCTagg5i3q9e1htdcGEBvcpq0l1b2RyQav4bItEKPcDh94c5z7d7EK", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +* Response Body if `offline` Mode + +```json +{ + "access_token": "AT-1-IBGuzWBdencAMz74LQkIuNcbLuu9WM3TYyacadkecrHUlcivs1GnWHjFmlkZPYg4TTAUM4", + "refresh_token": "RT-1-xfQXZaqXSQIJykCg2vnfdQjc5efVKdtteXaPo0OwCxWzIAacfC", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +#### Refresh Access Token + +In *`offline`* mode, the client application may request for a new access token by presenting the previously granted refresh token. + +* Request + +``` +https://accounts.osf.io/oauth2/token +``` + +* `POST` Body Parameters + +| Parameter | Value / Example | Note | +|---------------|-----------------------------------------------------------|------| +| refresh_token | `RT-1-xfQXZaqXSQIJykCg2vnfdQjc5efVKdtteXaPo0OwCxWzIAacfC` | | +| client_id | `ffe5247b810045a8a9277d3b3b4edc7a` | | +| client_secret | `5PgE96R3Z53dBuwBDkJfbK6ItDXvGhaxYpQ6r4cU` | | +| grant_type | `refresh_token` | | + +* Response Status + +``` +HTTP 200 OK +``` + +* Response Body + +```json +{ + "access_token": "AT-3-WbBmXVTsPlhUatrs5sQmilVLnA30wVv3holmfFCbIfePRjzQ6UXCb7LwJHGbFqmad3wNXu", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +

+ +### `GET /oauth2/profile` + +#### Profile + +Provides the user's principal ID, any released attributes and a list of granted scopes. + +* Request + +``` +https://accounts.osf.io/oauth2/profile +``` + +* Authorization Header + +| Name | Value / Example | Note | +|---------------|--------------------------------------------------------------------------------------|------| +| Authorization | `Bearer AT-4-IdanI4hWiybRzARBiLrlMdeMTlDJIqo1UgVLb4MHzbF13pNIT5POrfQTMW5yEyVD1oXXcz` | | + +* Response Status + +``` +HTTP 200 OK +``` + +* Response Body + +```json +{ + "scope": [ + "osf.full_read" + ], + "id": "f2t7d" +} +``` + +

+ +### `POST /oauth2/revoke` + +#### Revoke One Token + +Handles revocation of refresh and access tokens. + +* Request: Revoke one access token + +``` +https://accounts.osf.io/oauth2/revoke +``` + +* `POST` Body Parameters for revoke one access token + +| Name | Value / Example | Note | +|-------|-------------------------------------------------------------------------------|------| +| token | `AT-6-0ckMxjkBHgs5PMqbCtg9BgFo49Y60A1bC5QxFnQeWdiWe9ZfvKwWS52jyIwLrrwVMGFxfa` | | + +* `POST` Body Parameters for revoke one refresh token and all access token granted by this refresh token. + +| Name | Value / Example | Note | +|-------|-----------------------------------------------------------|------| +| token | `RT-1-xfQXZaqXSQIJykCg2vnfdQjc5efVKdtteXaPo0OwCxWzIAacfC` | | + +* Response Status + +``` +HTTP 204 NO CONTENT +``` + +#### Revoke Tokens for a Client Application + +Revokes all tokens associated with a client application specified by the given client ID. + +* Request + +``` +https://accounts.osf.io/oauth2/revoke +``` + +* `POST` Body Parameters + +| Parameter | Value / Example | Note | +|---------------|--------------------------------------------|------| +| client_id | `ffe5247b810045a8a9277d3b3b4edc7a` | | +| client_secret | `5PgE96R3Z53dBuwBDkJfbK6ItDXvGhaxYpQ6r4cU` | | + +* Response Status + +``` +HTTP 204 NO CONTENT +``` + +#### Revoke All Tokens for a Resource User + +Revokes all tokens of a client application that have been issued to a resource user. The application is specified by the client ID and the user is specified by the principal ID associated with the access token. The token used for authorization must have been generated by the application *unless it is of token type `CAS`*. + +* Request + +``` +https://accounts.osf.io/oauth2/revoke +``` + +* Authorization Header + +| Name | Value / Example | Note | +|---------------|--------------------------------------------------------------------------------------|------| +| Authorization | `Bearer AT-7-PvVw9wIcTOZYXFCVWbFhwsf9Q3idASiJeBdiWmLExcXSG54lCycokgCefWsy2Nzds4LoAW` | | + +* `POST` Body Parameters + +| Parameter | Value / Example | Note | +|-----------|------------------------------------|------| +| client_id | `ffe5247b810045a8a9277d3b3b4edc7a` | | + +* Response Status + +``` +HTTP 204 NO CONTENT +``` diff --git a/src/main/java/io/cos/cas/osf/authentication/handler/support/OsfPostgresAuthenticationHandler.java b/src/main/java/io/cos/cas/osf/authentication/handler/support/OsfPostgresAuthenticationHandler.java index e149eb51..3d43cf1d 100644 --- a/src/main/java/io/cos/cas/osf/authentication/handler/support/OsfPostgresAuthenticationHandler.java +++ b/src/main/java/io/cos/cas/osf/authentication/handler/support/OsfPostgresAuthenticationHandler.java @@ -165,14 +165,16 @@ protected final AuthenticationHandlerExecutionResult authenticateOsfPostgresInte if (oneTimePassword == null) { throw new OneTimePasswordRequiredException("2FA TOTP required for user [" + username + "]"); } + final long transformedOneTimePassword = Long.parseLong(oneTimePassword); + boolean checkPassed; try { - final long transformedOneTimePassword = Long.parseLong(oneTimePassword); - if (!TotpUtils.checkCode(osfTotp.getTotpSecretBase32(), transformedOneTimePassword)) { - throw new InvalidOneTimePasswordException("Invalid 2FA TOTP for user [" + username + "] (Type 1)"); - } - } catch (final Exception e) { + checkPassed = TotpUtils.checkCode(osfTotp.getTotpSecretBase32(), transformedOneTimePassword); + } catch (final Exception e){ throw new InvalidOneTimePasswordException("Invalid 2FA TOTP for user [" + username + "] (Type 2)"); } + if (!checkPassed) { + throw new InvalidOneTimePasswordException("Invalid 2FA TOTP for user [" + username + "] (Type 1)"); + } } if (!osfUser.isTermsOfServiceAccepted() && !isTermsOfServiceChecked) { diff --git a/src/main/java/io/cos/cas/osf/model/OsfTotp.java b/src/main/java/io/cos/cas/osf/model/OsfTotp.java index b89c4dfe..7d16ab09 100644 --- a/src/main/java/io/cos/cas/osf/model/OsfTotp.java +++ b/src/main/java/io/cos/cas/osf/model/OsfTotp.java @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.ToString; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.codec.binary.Base32; @@ -28,6 +29,7 @@ @NoArgsConstructor @Getter @ToString +@Slf4j public class OsfTotp extends AbstractOsfModel { @OneToOne @@ -50,8 +52,14 @@ private boolean isDeleted() { } public String getTotpSecretBase32() { - final byte[] bytes = DatatypeConverter.parseHexBinary(totpSecret); - return new Base32().encodeAsString(bytes); + try { + // Handle totpSecret generated before OSF Python 3.12 upgrade + final byte[] bytes = DatatypeConverter.parseHexBinary(totpSecret); + return new Base32().encodeAsString(bytes); + } catch (final IllegalArgumentException e) { + // Handle totpSecret generated after OSF Python 3.12 upgrade + return new Base32().encodeAsString(totpSecret.getBytes()); + } } public boolean isActive() {