身份验证后回调时出现 404 错误(Spring Boot + Angular + Okta)

Posted

技术标签:

【中文标题】身份验证后回调时出现 404 错误(Spring Boot + Angular + Okta)【英文标题】:Getting 404 error when callback after authentication(Spring Boot + Angular + Okta) 【发布时间】:2020-10-06 20:56:51 【问题描述】:

您好,我现在正在使用 Angular + Spring Boot 构建网站,在我的网站中,我正在使用 Okta 单页应用程序进行身份验证。对于前端,我使用的是 okta-angular,并按照此处的说明进行操作:https://github.com/okta/okta-oidc-js/tree/master/packages/okta-angular。我正在使用隐式流程。为了简单起见,我使用了 okta 托管的登录小部件。

我的前端代码是这样的:

app.module.ts

import 
  OKTA_CONFIG,
  OktaAuthModule
 from '@okta/okta-angular';

const oktaConfig = 
  issuer: 'https://yourOktaDomain.com/oauth2/default',
  clientId: 'clientId',
  redirectUri: 'http://localhost:port/implicit/callback',
  pkce: true


@NgModule(
  imports: [
    ...
    OktaAuthModule
  ],
  providers: [
     provide: OKTA_CONFIG, useValue: oktaConfig 
  ],
)
export class MyAppModule  

然后我在 app-routing.module.ts 中使用 OktaAuthGuard

import 
  OktaAuthGuard,
  ...
 from '@okta/okta-angular';

const appRoutes: Routes = [
  
    path: 'protected',
    component: MyProtectedComponent,
    canActivate: [ OktaAuthGuard ],
  ,
  ...
]

我也在 app-routing.module.ts 中使用 OktaCallBackComponent。

当然我在标题处有登录/注销按钮:

import  Component, OnInit  from '@angular/core';
import OktaAuthService from '@okta/okta-angular';

@Component(
  selector: 'app-header',
  templateUrl: './app-header.component.html',
  styleUrls: ['./app-header.component.scss']
)
export class AppHeaderComponent implements OnInit 
  isAuthenticated: boolean;
  constructor(public oktaAuth: OktaAuthService) 
    // Subscribe to authentication state changes
    this.oktaAuth.$authenticationState.subscribe(
      (isAuthenticated: boolean) => this.isAuthenticated = isAuthenticated
    );
  
  async ngOnInit() 
    this.isAuthenticated = await this.oktaAuth.isAuthenticated();
  

  login() 
    this.oktaAuth.loginRedirect('/');
  

  logout() 
    this.oktaAuth.logout('/');
  


<nav class="navbar navbar-expand-lg navbar-light">

  <div class="collapse navbar-collapse" id="navbarSupportedContent">
    <ul class="navbar-nav mr-auto">
      <li class="nav-item">
        <a class="nav-link" *ngIf="!isAuthenticated" (click)="login()"> Login </a>
        <a class="nav-link" *ngIf="isAuthenticated" (click)="logout()"> Logout </a>
      </li>
    </ul>
  </div>
</nav>

用户在前端登录后,我会将Authoirization标头传递给后端,然后 在后端,我使用 spring security 来保护后端 api。 像这样:

import com.okta.spring.boot.oauth.Okta;
import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;

@RequiredArgsConstructor
@EnableWebSecurity
public class OktaOAuth2WebSecurityConfig extends WebSecurityConfigurerAdapter 

    @Override
    protected void configure(HttpSecurity http) throws Exception 
        // Disable CSRF (cross site request forgery)
        http.csrf().disable();

        // No session will be created or used by spring security
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.authorizeRequests()
                .antMatchers("/api/**").authenticated()
                .and()
                .oauth2ResourceServer().opaqueToken();

        Okta.configureResourceServer401ResponseBody(http);
    

如果我在终端中分别运行 angular 和 spring boot,一切正常。我可以登录,我可以在后台获取用户信息。

但问题是当我们使用 gradle build 和 deploy 时,我们会将 angular 编译后的代码放到 spring boot 项目下的 static 文件夹中。这时候如果我运行项目:

java -jar XX.jar

我在 localhost:8080 打开。

我登录了,那么此时认证回调会抛出404 not found错误。

据我了解,原因是当我运行jar文件时,我没有为“回调”url定义控制器。但是如果我分别运行 angular 和 spring boot,angular 由 nodejs 托管,并且我使用了 okta 回调组件,所以一切正常。

那么我应该怎么做才能解决这个问题呢?我的意思是,我应该怎么做才能让它作为 jar 文件工作?我应该定义一个回调控制器吗?但是我应该在回调控制器中做什么?会不会和前端代码冲突??

【问题讨论】:

我在想如果我需要将角度放入 jar 文件中,是否必须将登录注销放到后端而不是前端?喜欢使用这个示例:github.com/okta/samples-java-spring/blob/master/… 【参考方案1】:

你很幸运!我今天刚刚发布了blog post,它展示了如何使用单独运行的 Angular + Spring Boot 应用程序(使用 Okta 的 SDK)并将它们打包在一个 JAR 中。您仍然可以使用ng serve./gradlew bootRun 独立开发每个应用程序,但您也可以使用./gradlew bootRun -Pprod 在单个实例中运行它们。在 prod 模式下运行的缺点是您不会在 Angular 中进行热重载。这是上述教程中的the steps I used。

创建一个新的 AuthService 服务,该服务将与您的 Spring Boot API 进行通信以进行身份​​验证逻辑。

import  Injectable  from '@angular/core';
import  Location  from '@angular/common';
import  BehaviorSubject, Observable  from 'rxjs';
import  HttpClient, HttpHeaders  from '@angular/common/http';
import  environment  from '../../environments/environment';
import  User  from './user';
import  map  from 'rxjs/operators';

const headers = new HttpHeaders().set('Accept', 'application/json');

@Injectable(
  providedIn: 'root'
)
export class AuthService 
  $authenticationState = new BehaviorSubject<boolean>(false);

  constructor(private http: HttpClient, private location: Location) 
  

  getUser(): Observable<User> 
    return this.http.get<User>(`$environment.apiUrl/user`, headers).pipe(
      map((response: User) => 
        if (response !== null) 
          this.$authenticationState.next(true);
          return response;
        
      )
    );
  

  isAuthenticated(): Promise<boolean> 
    return this.getUser().toPromise().then((user: User) =>  
      return user !== undefined;
    ).catch(() => 
      return false;
    )
  

  login(): void  
    location.href =
      `$location.origin$this.location.prepareExternalUrl('oauth2/authorization/okta')`;
  

  logout(): void  
    const redirectUri = `$location.origin$this.location.prepareExternalUrl('/')`;

    this.http.post(`$environment.apiUrl/api/logout`, ).subscribe((response: any) => 
      location.href = response.logoutUrl + '?id_token_hint=' + response.idToken
        + '&post_logout_redirect_uri=' + redirectUri;
    );
  

在同一目录中创建一个user.ts 文件,以保存您的User 模型。

export class User 
  sub: number;
  fullName: string;

更新app.component.ts 以使用您的新AuthService 来支持OktaAuthService

import  Component, OnInit  from '@angular/core';
import  AuthService  from './shared/auth.service';

@Component(
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
)
export class AppComponent implements OnInit 
  title = 'Notes';
  isAuthenticated: boolean;
  isCollapsed = true;

  constructor(public auth: AuthService) 
  

  async ngOnInit() 
    this.isAuthenticated = await this.auth.isAuthenticated();
    this.auth.$authenticationState.subscribe(
      (isAuthenticated: boolean)  => this.isAuthenticated = isAuthenticated
    );
  

app.component.html 中的按钮更改为引用auth 服务而不是oktaAuth

<button *ngIf="!isAuthenticated" (click)="auth.login()"
        class="btn btn-outline-primary" id="login">Login</button>
<button *ngIf="isAuthenticated" (click)="auth.logout()"
        class="btn btn-outline-secondary" id="logout">Logout</button>

更新home.component.ts 以使用AuthService

import  Component, OnInit  from '@angular/core';
import  AuthService  from '../shared/auth.service';

@Component(
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.scss']
)
export class HomeComponent implements OnInit 
  isAuthenticated: boolean;

  constructor(public auth: AuthService) 
  

  async ngOnInit() 
    this.isAuthenticated = await this.auth.isAuthenticated();
  

如果您使用 OktaDev Schematics 将 Okta 集成到您的 Angular 应用程序中,请删除 src/app/auth-routing.module.tssrc/app/shared/okta

修改app.module.ts以删除AuthRoutingModule导入,添加HomeComponent作为声明,并导入HttpClientModule

HomeComponent 的路由添加到app-routing.module.ts

import  HomeComponent  from './home/home.component';

const routes: Routes = [
   path: '', redirectTo: '/home', pathMatch: 'full' ,
  
    path: 'home',
    component: HomeComponent
  
];

创建一个proxy.conf.js 文件以将某些请求代理到http://localhost:8080 上的 Spring Boot API。

const PROXY_CONFIG = [
  
    context: ['/user', '/api', '/oauth2', '/login'],
    target: 'http://localhost:8080',
    secure: false,
    logLevel: "debug"
  
]

module.exports = PROXY_CONFIG;

将此文件添加为angular.json 中的proxyConfig 选项。

"serve": 
  "builder": "@angular-devkit/build-angular:dev-server",
  "options": 
    "browserTarget": "notes:build",
    "proxyConfig": "src/proxy.conf.js"
  ,
  ...
,

从您的 Angular 项目中删除 Okta 的 Angular SDK 和 OktaDev Schematics。

npm uninstall @okta/okta-angular @oktadev/schematics

此时,您的 Angular 应用程序将不包含任何 Okta 特定的身份验证代码。相反,它依赖于您的 Spring Boot 应用程序来提供。

要配置您的 Spring Boot 应用程序以包含 Angular,您需要在传入 -Pprod 时配置 Gradle(或 Maven)以构建您的 Spring Boot 应用程序,您需要调整路由以支持 SPA,并且修改 Spring Security 以允许访问 HTML、CSS 和 javascript

在我的示例中,我使用了 Gradle 和 Kotlin。

首先,创建一个RouteController.kt,将所有请求路由到index.html

package com.okta.developer.notes

import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.RequestMapping
import javax.servlet.http.HttpServletRequest

@Controller
class RouteController 

    @RequestMapping(value = ["/path:[^\\.]*"])
    fun redirect(request: HttpServletRequest): String 
        return "forward:/"
    

修改 SecurityConfiguration.kt 以允许匿名访问静态 Web 文件、/user 信息端点,并添加额外的安全标头。

package com.okta.developer.notes

import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.web.csrf.CookieCsrfTokenRepository
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter
import org.springframework.security.web.util.matcher.RequestMatcher

@EnableWebSecurity
class SecurityConfiguration : WebSecurityConfigurerAdapter() 

    override fun configure(http: HttpSecurity) 
        //@formatter:off
        http
            .authorizeRequests()
                .antMatchers("/**/*.js,html,css").permitAll()
                .antMatchers("/", "/user").permitAll()
                .anyRequest().authenticated()
                .and()
            .oauth2Login()
                .and()
            .oauth2ResourceServer().jwt()

        http.requiresChannel()
                .requestMatchers(RequestMatcher 
                    r -> r.getHeader("X-Forwarded-Proto") != null
                ).requiresSecure()

        http.csrf()
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())

        http.headers()
                .contentSecurityPolicy("script-src 'self'; report-to /csp-report-endpoint/")
                .and()
                .referrerPolicy(ReferrerPolicyHeaderWriter.ReferrerPolicy.SAME_ORIGIN)
                .and()
                .featurePolicy("accelerometer 'none'; camera 'none'; microphone 'none'")

        //@formatter:on
    

创建一个UserController.kt,可以用来判断用户是否登录。

package com.okta.developer.notes

import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.core.oidc.user.OidcUser
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class UserController() 

    @GetMapping("/user")
    fun user(@AuthenticationPrincipal user: OidcUser?): OidcUser? 
        return user;
    

以前,Angular 处理注销。添加一个LogoutController,它将处理会话到期以及将信息发送回 Angular,以便它可以从 Okta 注销。

package com.okta.developer.notes

import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.client.registration.ClientRegistration
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
import org.springframework.security.oauth2.core.oidc.OidcIdToken
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController
import javax.servlet.http.HttpServletRequest

@RestController
class LogoutController(val clientRegistrationRepository: ClientRegistrationRepository) 

    val registration: ClientRegistration = clientRegistrationRepository.findByRegistrationId("okta");

    @PostMapping("/api/logout")
    fun logout(request: HttpServletRequest,
               @AuthenticationPrincipal(expression = "idToken") idToken: OidcIdToken): ResponseEntity<*> 
        val logoutUrl = this.registration.providerDetails.configurationMetadata["end_session_endpoint"]
        val logoutDetails: MutableMap<String, String> = HashMap()
        logoutDetails["logoutUrl"] = logoutUrl.toString()
        logoutDetails["idToken"] = idToken.tokenValue
        request.session.invalidate()
        return ResponseEntity.ok().body<Map<String, String>>(logoutDetails)
    

最后,我将 Gradle 配置为构建一个包含 Angular 的 JAR。

首先导入NpmTask 并在build.gradle.kts 中添加Node Gradle 插件:

import com.moowork.gradle.node.npm.NpmTask

plugins 
    ...
    id("com.github.node-gradle.node") version "2.2.4"
    ...

然后,定义 Angular 应用的位置和 Node 插件的配置。

val spa = "$projectDir/../notes";

node 
    version = "12.16.2"
    nodeModulesDir = file(spa)

添加buildWeb 任务:

val buildWeb = tasks.register<NpmTask>("buildNpm") 
    dependsOn(tasks.npmInstall)
    setNpmCommand("run", "build")
    setArgs(listOf("--", "--prod"))
    inputs.dir("$spa/src")
    inputs.dir(fileTree("$spa/node_modules").exclude("$spa/.cache"))
    outputs.dir("$spa/dist")

并在传入-Pprod 时修改processResources 任务以构建Angular。

tasks.processResources 
    rename("application-$profile.properties", "application.properties")
    if (profile == "prod") 
        dependsOn(buildWeb)
        from("$spa/dist/notes") 
            into("static")
        
    

现在您应该可以使用./gradlew bootJar -Pprod 组合这两个应用程序,或者使用./gradlew bootRun -Pprod 查看它们的运行情况。

【讨论】:

【参考方案2】:

对于一个简单的解决方案,我在 spring boot 中添加了一个配置文件,以将隐式/回调重新路由到 angular "index.html":

import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;

import java.io.IOException;

@Configuration
public class ReroutingConfig implements WebMvcConfigurer 

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) 
        registry.addResourceHandler("/implicit/**", "/home")
                .addResourceLocations("classpath:/static/")
                .resourceChain(true)
                .addResolver(new PathResourceResolver() 
                    @Override
                    protected Resource getResource(String resourcePath, Resource location) throws IOException 
                        Resource requestedResource = location.createRelative(resourcePath);

                        return requestedResource.exists() && requestedResource.isReadable() ? requestedResource
                                : new ClassPathResource("/static/index.html");
                    
                );
    


它有效,但我不确定这是否是一个好习惯。

【讨论】:

你当然可以这样做,但它不如我的版本安全。这是因为它将访问令牌存储在客户端而不是服务器上。从服务器上的会话中窃取访问令牌比从浏览器中窃取访问令牌要困难得多。但是,这可能是您无需担心的事情,只要营销部门没有人添加可能会受到损害的跟踪器和第 3 方脚本。我会确保你有一个可靠的 CSP。 @MattRaible 有一件事是,如果我们想在 Spring Boot 端进行身份验证,更简单的方法是“Web”Okta 应用程序,对吧?

以上是关于身份验证后回调时出现 404 错误(Spring Boot + Angular + Okta)的主要内容,如果未能解决你的问题,请参考以下文章

使用 Spring Security 实现 LDAP 身份验证时出现 Gradle 依赖关系错误

Spring Social - facebook登录时出现404错误

使用 nuxt auth 和 laravel 护照登录时出现错误 404

显示来自 Firebase 存储的图像时出现 404 错误

使用 CorsFilter 和 spring security 时出现 Cors 错误

获取用户数据时出现未经身份验证的错误