认证后回调时获取404错误(Spring Boot + Angular + Okta)

问题描述 投票:2回答:2

[嗨,我现在正在使用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);
    }
}

如果我分别在终端中运行角钢靴和弹簧靴,一切正常。我可以登录,并且可以在后端获取用户信息。

但是问题是,当我们使用gradle构建并部署时,我们会将有角编译的代码放到spring boot项目下的静态文件夹中。这时如果我运行项目:

java -jar XX.jar

然后我在localhost:8080打开。

我登录,这时,身份验证回调将引发404 not found错误。

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

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

java angular spring-boot spring-security okta
2个回答
3
投票

您很幸运!我今天刚刚发布了一个blog post,它显示了如何获取一个单独运行(与Okta的SDK一起运行)的Angular + Spring Boot应用程序,并将其打包在一个JAR中。您仍然可以使用ng serve./gradlew bootRun独立开发每个应用程序,但是也可以使用./gradlew bootRun -Pprod在单个实例中运行它们。在生产模式下运行的缺点是您不会在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;

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

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

从您的Angular项目中删除Okta的Angular SDK和OktaDev示意图。

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

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

要配置Spring Boot应用程序以包含Angular,您需要配置Gradle(或Maven)以在传入-Pprod时构建Spring Boot应用程序,您需要将路由调整为可识别SPA,并修改Spring Security允​​许访问HTML,CSS和JavaScript。

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

首先,创建一个将所有请求路由到RouteController.ktindex.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")
}

并在传入processResources时修改-Pprod任务以构建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看到它们正在运行。


1
投票

作为一个简单的解决方案,我在春季启动时添加了一个配置文件,以将隐式/回调重新路由到角度“ 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");
                    }
                });
    }

}

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

© www.soinside.com 2019 - 2024. All rights reserved.