From a9710dfd3b4fc6a6bbc0e2000e5badead8f3236a Mon Sep 17 00:00:00 2001 From: thiner Date: Thu, 13 Jan 2022 11:13:46 +0800 Subject: [PATCH] Create SpringBoot_SPA_OAuth2_Example --- SpringBoot_SPA_OAuth2_Example | 371 ++++++++++++++++++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 SpringBoot_SPA_OAuth2_Example diff --git a/SpringBoot_SPA_OAuth2_Example b/SpringBoot_SPA_OAuth2_Example new file mode 100644 index 0000000..0fc89a1 --- /dev/null +++ b/SpringBoot_SPA_OAuth2_Example @@ -0,0 +1,371 @@ +--- +presentation: + theme: beige.css + width: 1024 + height: 900 + controls: true + enableSpeakerNotes: true +--- + +## 1. Register Your API and Web App On AAD + + +Follow the [official manual](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-configure-app-expose-web-apis) to expose your API. + +Key points: +- Copy the `Application ID URI`. + + +Follow the [official manual](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-configure-app-access-web-apis) to register the web app. + +Key points: +- Enable `Implicit Flow` for the web app +- Set the `Redirect Url` to your web app home page +- Add API to the permission list + + +## 2. Configure OAuth2 Token Validator on Backend Side + + +Assuming you are using Spring Boot and Spring Security +Add `spring-boot-starter-parent` in pom.xml +```xml + + org.springframework.boot + spring-boot-starter-parent + 2.1.6.RELEASE + + +``` + + +Add dependencies +```xml + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.security.oauth + spring-security-oauth2 + 2.2.4.RELEASE + + + org.springframework.security + spring-security-oauth2-jose + + + org.springframework.security + spring-security-oauth2-resource-server + + + org.springframework.security.oauth.boot + spring-security-oauth2-autoconfigure + 2.1.5.RELEASE + + +``` + +Spring Security Configuration +```java +@Configuration +@EnableWebSecurity +@EnableResourceServer +public class SecurityConfig extends WebSecurityConfigurerAdapter { + @Override + protected void configure(HttpSecurity http) throws Exception { + http + // enable JWT token validation + .oauth2ResourceServer() + .jwt() + .and() + .authenticationEntryPoint(aadEntryPoint); + } +} + +// the customized entry point +@Component +public class AADLoginEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + /* + * On API side, we are not responsible to handle user authentication. + * So we simply response 401 error here for requests with invalid token. + */ + response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized"); + } +} +``` + + +Configure JWT token validator +```java +@Bean +public JwtDecoder jwtDecoder() { + NimbusJwtDecoder decoder = (NimbusJwtDecoder)JwtDecoders.fromOidcIssuerLocation("https://sts.windows.net/" + this.tenantId + "/"); + // verify expiry time and audience in the token + DelegatingOAuth2TokenValidator tokenValidators = new DelegatingOAuth2TokenValidator<>( + // the maximum time skew allowed if token is found expired + new JwtTimestampValidator(Duration.ofSeconds(60)), + new AudienceValidator()); + decoder.setJwtValidator(tokenValidators); + return decoder; +} + +class AudienceValidator implements OAuth2TokenValidator { + OAuth2Error error = new OAuth2Error("invalid_token", "Invalid token.", null); + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + // resourceId is the Application ID URI of registered api in AAD previously + if (null != jwt.getAudience() && jwt.getAudience().stream().anyMatch(a -> a != null && a.equalsIgnoreCase(resourceId))) { + return OAuth2TokenValidatorResult.success(); + } else { + return OAuth2TokenValidatorResult.failure(error); + } + } +} +``` + + +## 3. Setup OAuth2 Client In Web App + + +Add _MSAL_ dependency in `package.json` +```json +"dependencies": { + "axios": "^0.19.2", + "msal": "^1.2.2", + ... +} +``` + + +Configure MSAL client + +```js +import * as Msal from 'msal'; + +const msalConfig = { + auth: { + clientId: '[App id of web app registered in AAD]', + redirectUri: '[redirect url of the web app registered in AAD]' + }, + cache: { + cacheLocation: 'sessionStorage' + } +}; +``` + + +Sign in user +```js +signIn() { + if (!this.msalInstance) { + this.initialize(); + } + var loginRequest = { + }; + return new Promise((resolve, reject) => { + if (!this.msalInstance.getAccount()) { + this.msalInstance + .loginRedirect(loginRequest) + .then(resp => { + resolve(resp); + }) + .catch(err => { + reject(err); + }); + } + resolve(this.msalInstance.getAccount()); + }); +}, +``` +Call the signIn function while initializing the page +```js +auth.signIn().then(function(token) { // this is the id token + var user = { + userId: token.userName, + userName: token.name + } + // do something after user sign in, e.g. render your page +}) +``` + + +Before call api, you need acquire access token +```js +acquireToken() { + if (!this.msalInstance) { + this.initialize(); + } + return new Promise((resolve, reject) => { + if (this.msalInstance.getAccount()) { + var tokenRequest = { + // resourceId is the App ID URI of the registered api in AAD + scopes: [resourceId + '/.default'] + }; + this.msalInstance + .acquireTokenSilent(tokenRequest) + .then(r => { + resolve(r.accessToken); + }) + .catch(err => { + // could also check if err instance of InteractionRequiredAuthError if you can import the class. + if (err.name === 'InteractionRequiredAuthError') { + return this.msalInstance + .acquireTokenRedirect(tokenRequest) + .then(r => { + resolve(r.accessToken); + }) + .catch(err => { + reject(err); + }); + } + }); + } else { + this.msalInstance.loginRedirect(); + } + }); +}, +``` + + +then call the api with the access token +```js +import axios from 'axios' +import auth from '../auth' + +// request interceptors +axios.interceptors.request.use( + async function (config) { + config.headers = config.headers || {}; + var token = await auth.acquireToken(); + config.headers.Authorization = "Bearer " + token; + return config; + }, + function(err) { + // Do something with request error + return Promise.reject(err); + } +); + +// api request example +axios.get('http://localhost:8080/api/v1/users') + .then() + .catch() +``` + + +Complete auth.js for your reference +```js +import * as Msal from 'msal'; +import config from './config'; + +const msalConfig = { + auth: { + clientId: config.oauth.clientId, + redirectUri: config.oauth.redirectUri + }, + cache: { + cacheLocation: 'sessionStorage' + } +}; + +export default { + msalInstance: null, + initialize() { + // msal instance + this.msalInstance = new Msal.UserAgentApplication(msalConfig); + this.msalInstance.handleRedirectCallback((err) => { + if (err) { + console.log(err) + } + }); + }, + /** + * @return {Promise.} A promise that resolves to an access token for resource access + */ + acquireToken() { + if (!this.msalInstance) { + this.initialize(); + } + return new Promise((resolve, reject) => { + if (this.msalInstance.getAccount()) { + var tokenRequest = { + scopes: [config.api.resourceId + '/.default'] + }; + this.msalInstance + .acquireTokenSilent(tokenRequest) + .then(r => { + resolve(r.accessToken); + }) + .catch(err => { + // could also check if err instance of InteractionRequiredAuthError if you can import the class. + if (err.name === 'InteractionRequiredAuthError') { + return this.msalInstance + .acquireTokenRedirect(tokenRequest) + .then(r => { + resolve(r.accessToken); + }) + .catch(err => { + reject(err); + }); + } + }); + } else { + this.msalInstance.loginRedirect(); + } + }); + }, + // to be continue... + +``` + + +Continued +```js +isAuthenticated() { + return this.msalInstance && this.msalInstance.getAccount() + }, + /** + * Sign in user. + */ + signIn() { + if (!this.msalInstance) { + this.initialize(); + } + var loginRequest = { + }; + return new Promise((resolve, reject) => { + if (!this.msalInstance.getAccount()) { + this.msalInstance + .loginRedirect(loginRequest) + .then(resp => { + resolve(resp); + }) + .catch(err => { + reject(err); + }); + } + resolve(this.msalInstance.getAccount()); + }); + }, + signOut() { + this.msalInstance.logout(); + } +}; +``` + + +# -End-