Получение ошибки 404 при обратном вызове после аутентификации (Spring Boot + Angular + Okta) - PullRequest
2 голосов
/ 17 июня 2020

Привет, я сейчас использую 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 { }

, затем я использую OktaAuthGuard в app-routing.module.ts

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 для защиты backend 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 и загружаю отдельно в терминалах. Я могу войти в систему и получить информацию о пользователе на бэкэнде.

Но проблема в том, что когда мы использовали сборку gradle и для развертывания, мы поместим angular скомпилированный код в папку stati c под spring загрузочный проект. В это время, если я запускаю проект:

java -jar XX.jar

И открываюсь на localhost: 8080.

Я вхожу в систему, тогда в это время обратный вызов аутентификации выдаст ошибку 404 not found.

Насколько я понимаю, причина в том, что когда я запускаю jar, и я не определял контроллер для URL-адреса "обратного вызова". Но если я запускаю angular и весеннюю загрузку отдельно, angular размещается на nodejs, и я использовал компонент обратного вызова okta, поэтому все работает.

Итак, что мне делать, чтобы решить проблему? Я имею в виду, что мне делать, чтобы он работал как файл jar? я должен определить контроллер обратного вызова? но что мне делать в контроллере обратного вызова? будет ли он конфликтовать с кодом внешнего интерфейса ??

Ответы [ 2 ]

3 голосов
/ 17 июня 2020

Тебе повезло! Я только что опубликовал сообщение в блоге сегодня, в котором показано, как взять приложение Angular + Spring Boot, которое запускается отдельно (с SDK Okta), и упаковать их в один JAR. Вы по-прежнему можете разрабатывать каждое приложение независимо, используя ng serve и ./gradlew bootRun, но вы также можете запускать их в одном экземпляре, используя ./gradlew bootRun -Pprod. Недостатком работы в режиме prod является то, что вы не получите горячей перезагрузки в Angular. Вот шаги, которые я использовал в вышеупомянутом руководстве.

Создайте новую службу AuthService, которая будет взаимодействовать с вашим Spring Boot API для аутентификации logi c.

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.ts и src/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 для проксирования определенных запросов к ваш Spring Boot API на http://localhost:8080.

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 SDK Okta и OktaDev Sc hematics из вашего проекта Angular.

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

На этом этапе ваше приложение Angular не будет содержать код c, специфичный для Okta, для аутентификации. Вместо этого он полагается на ваше приложение Spring Boot, чтобы предоставить это.

Чтобы настроить приложение Spring Boot для включения Angular, вам необходимо настроить Gradle (или Maven) для создания приложения Spring Boot при переходе -Pprod, вам необходимо настроить маршруты, чтобы они поддерживали 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, чтобы разрешить анонимный доступ к stati c веб-файлам, /user info endpoint и для добавления дополнительных заголовков безопасности.

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 для создания JAR с Angular включен.

Начните с импорта NpmTask и добавления подключаемого модуля Node Gradle в build.gradle.kts:

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

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

Затем определите расположение вашего Angular приложения и конфигурацию для узла плагин.

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 на сборку Angular при передаче -Pprod.

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 голосов
/ 17 июня 2020

Для простого решения я добавил файл конфигурации при весенней загрузке, чтобы перенаправить неявный / обратный вызов в 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");
                    }
                });
    }

}

Это работает, но я не уверен, что это хорошая практика.

...