«1. Обзор

В этом руководстве мы защитим REST API с помощью OAuth2 и используем его из простого клиента Angular.

Приложение, которое мы собираемся создать, будет состоять из трех отдельных модулей:

    Сервер авторизации Код авторизации пользовательского интерфейса сервера ресурсов: внешнее приложение, использующее поток кода авторизации

Мы будем использовать стек OAuth в Spring Security 5. Если вы хотите использовать устаревший стек Spring Security OAuth, ознакомьтесь с этой предыдущей статьей: Spring REST API + OAuth2 + Angular (использование устаревшего стека Spring Security OAuth).

Давайте сразу приступим.

2. Сервер авторизации OAuth2 (AS)

Проще говоря, сервер авторизации — это приложение, которое выдает токены для авторизации.

Ранее стек Spring Security OAuth предлагал возможность настроить сервер авторизации как приложение Spring. Но проект устарел, главным образом потому, что OAuth является открытым стандартом со многими хорошо зарекомендовавшими себя поставщиками, такими как Okta, Keycloak и ForgeRock, и это лишь некоторые из них.

Из них мы будем использовать Keycloak. Это сервер управления идентификацией и доступом с открытым исходным кодом, администрируемый Red Hat, разработанный на Java компанией JBoss. Он поддерживает не только OAuth2, но и другие стандартные протоколы, такие как OpenID Connect и SAML.

В этом руководстве мы настроим встроенный сервер Keycloak в приложении Spring Boot.

3. Сервер ресурсов (RS)

Теперь давайте обсудим сервер ресурсов; по сути, это REST API, который мы в конечном итоге хотим использовать.

3.1. Конфигурация Maven

pom нашего сервера ресурсов почти такой же, как и pom предыдущего сервера авторизации, без части Keycloak и с дополнительной зависимостью spring-boot-starter-oauth2-resource-server:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

3.2. Конфигурация безопасности

Поскольку мы используем Spring Boot, мы можем определить минимальную требуемую конфигурацию, используя свойства загрузки.

Мы сделаем это в файле application.yml:

server: 
  port: 8081
  servlet: 
    context-path: /resource-server

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8083/auth/realms/baeldung
          jwk-set-uri: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/certs

Здесь мы указали, что будем использовать токены JWT для авторизации.

Свойство jwk-set-uri указывает на URI, содержащий открытый ключ, чтобы наш сервер ресурсов мог проверить целостность токенов.

Свойство issuer-uri представляет собой дополнительную меру безопасности для проверки эмитента токенов (который является сервером авторизации). Однако добавление этого свойства также требует, чтобы сервер авторизации был запущен, прежде чем мы сможем запустить приложение сервера ресурсов.

Далее давайте настроим конфигурацию безопасности для API для защиты конечных точек:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors()
            .and()
              .authorizeRequests()
                .antMatchers(HttpMethod.GET, "/user/info", "/api/foos/**")
                  .hasAuthority("SCOPE_read")
                .antMatchers(HttpMethod.POST, "/api/foos")
                  .hasAuthority("SCOPE_write")
                .anyRequest()
                  .authenticated()
            .and()
              .oauth2ResourceServer()
                .jwt();
    }
}

Как мы видим, для наших методов GET мы разрешаем только запросы, имеющие область чтения. Для метода POST запрашивающая сторона должна иметь права на запись в дополнение к чтению. Однако для любой другой конечной точки запрос должен быть просто аутентифицирован любым пользователем.

Кроме того, метод oauth2ResourceServer() указывает, что это сервер ресурсов с токенами в формате jwt().

Еще один момент, на который стоит обратить внимание, это использование метода cors() для разрешения заголовков Access-Control в запросах. Это особенно важно, поскольку мы имеем дело с клиентом Angular, и наши запросы будут поступать с другого URL-адреса источника.

3.4. Модель и репозиторий

Далее давайте определим javax.persistence.Entity для нашей модели Foo:

@Entity
public class Foo {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    
    // constructor, getters and setters
}

Затем нам понадобится репозиторий Foos. Мы будем использовать Spring PagingAndSortingRepository:

public interface IFooRepository extends PagingAndSortingRepository<Foo, Long> {
}

3.4. Сервис и реализация

После этого мы определим и реализуем простой сервис для нашего API:

public interface IFooService {
    Optional<Foo> findById(Long id);

    Foo save(Foo foo);
    
    Iterable<Foo> findAll();

}

@Service
public class FooServiceImpl implements IFooService {

    private IFooRepository fooRepository;

    public FooServiceImpl(IFooRepository fooRepository) {
        this.fooRepository = fooRepository;
    }

    @Override
    public Optional<Foo> findById(Long id) {
        return fooRepository.findById(id);
    }

    @Override
    public Foo save(Foo foo) {
        return fooRepository.save(foo);
    }

    @Override
    public Iterable<Foo> findAll() {
        return fooRepository.findAll();
    }
}

3.5. Пример контроллера

Теперь давайте реализуем простой контроллер, открывающий наш ресурс Foo через DTO:

@RestController
@RequestMapping(value = "/api/foos")
public class FooController {

    private IFooService fooService;

    public FooController(IFooService fooService) {
        this.fooService = fooService;
    }

    @CrossOrigin(origins = "http://localhost:8089")    
    @GetMapping(value = "/{id}")
    public FooDto findOne(@PathVariable Long id) {
        Foo entity = fooService.findById(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        return convertToDto(entity);
    }

    @GetMapping
    public Collection<FooDto> findAll() {
        Iterable<Foo> foos = this.fooService.findAll();
        List<FooDto> fooDtos = new ArrayList<>();
        foos.forEach(p -> fooDtos.add(convertToDto(p)));
        return fooDtos;
    }

    protected FooDto convertToDto(Foo entity) {
        FooDto dto = new FooDto(entity.getId(), entity.getName());

        return dto;
    }
}

Обратите внимание на использование @CrossOrigin выше; это конфигурация уровня контроллера, которая нам нужна, чтобы разрешить запуск CORS из нашего приложения Angular по указанному URL-адресу.

Вот наш FooDto:

public class FooDto {
    private long id;
    private String name;
}

4. Интерфейс — настройка

Теперь мы рассмотрим простую реализацию интерфейса Angular для клиента, который будет иметь доступ к нашему REST API.

Сначала мы будем использовать Angular CLI для создания и управления нашими интерфейсными модулями.

«Сначала мы устанавливаем node и npm, так как Angular CLI — это инструмент npm.

Затем нам нужно использовать frontend-maven-plugin для сборки нашего проекта Angular с помощью Maven:

<build>
    <plugins>
        <plugin>
            <groupId>com.github.eirslett</groupId>
            <artifactId>frontend-maven-plugin</artifactId>
            <version>1.3</version>
            <configuration>
                <nodeVersion>v6.10.2</nodeVersion>
                <npmVersion>3.10.10</npmVersion>
                <workingDirectory>src/main/resources</workingDirectory>
            </configuration>
            <executions>
                <execution>
                    <id>install node and npm</id>
                    <goals>
                        <goal>install-node-and-npm</goal>
                    </goals>
                </execution>
                <execution>
                    <id>npm install</id>
                    <goals>
                        <goal>npm</goal>
                    </goals>
                </execution>
                <execution>
                    <id>npm run build</id>
                    <goals>
                        <goal>npm</goal>
                    </goals>
                    <configuration>
                        <arguments>run build</arguments>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

И, наконец, сгенерировать новый модуль с помощью Angular CLI:

ng new oauthApp

В следующем разделе мы обсудим логику приложения Angular.

5. Поток кода авторизации с использованием Angular

Здесь мы собираемся использовать поток кода авторизации OAuth2.

Наш вариант использования: клиентское приложение запрашивает код с сервера авторизации и получает страницу входа. Как только пользователь предоставляет свои действительные учетные данные и отправляет их, сервер авторизации предоставляет нам код. Затем внешний клиент использует его для получения токена доступа.

5.1. Домашний компонент

Давайте начнем с нашего основного компонента, HomeComponent, где все действие начинается:

@Component({
  selector: 'home-header',
  providers: [AppService],
  template: `<div class="container" >
    <button *ngIf="!isLoggedIn" class="btn btn-primary" (click)="login()" type="submit">
      Login</button>
    <div *ngIf="isLoggedIn" class="content">
      <span>Welcome !!</span>
      <a class="btn btn-default pull-right"(click)="logout()" href="#">Logout</a>
      <br/>
      <foo-details></foo-details>
    </div>
  </div>`
})
 
export class HomeComponent {
  public isLoggedIn = false;

  constructor(private _service: AppService) { }
 
  ngOnInit() {
    this.isLoggedIn = this._service.checkCredentials();    
    let i = window.location.href.indexOf('code');
    if(!this.isLoggedIn && i != -1) {
      this._service.retrieveToken(window.location.href.substring(i + 5));
    }
  }

  login() {
    window.location.href = 
      'http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/auth?
         response_type=code&scope=openid%20write%20read&client_id=' + 
         this._service.clientId + '&redirect_uri='+ this._service.redirectUri;
    }
 
  logout() {
    this._service.logout();
  }
}

В начале, когда пользователь не вошел в систему, появляется только кнопка входа. После нажатия этой кнопки пользователь переходит к URL-адресу авторизации AS, где он вводит имя пользователя и пароль. После успешного входа пользователь перенаправляется обратно с кодом авторизации, а затем мы получаем токен доступа с помощью этого кода.

5.2. Служба приложений

Теперь давайте посмотрим на службу AppService, расположенную по адресу app.service.ts, которая содержит логику для взаимодействия с сервером: сохранить наш токен доступа в файле cookie, используя библиотеку ng2-cookies. getResource(): чтобы получить объект Foo с сервера, используя его идентификатор. the user out

    В методе retrieveToken мы используем наши учетные данные клиента и базовую аутентификацию для отправки POST в конечную точку /openid-connect/token для получения маркера доступа. Параметры передаются в формате URL-кодировки. После получения токена доступа мы сохраняем его в файле cookie.
export class Foo {
  constructor(public id: number, public name: string) { }
} 

@Injectable()
export class AppService {
  public clientId = 'newClient';
  public redirectUri = 'http://localhost:8089/';

  constructor(private _http: HttpClient) { }

  retrieveToken(code) {
    let params = new URLSearchParams();   
    params.append('grant_type','authorization_code');
    params.append('client_id', this.clientId);
    params.append('client_secret', 'newClientSecret');
    params.append('redirect_uri', this.redirectUri);
    params.append('code',code);

    let headers = 
      new HttpHeaders({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'});
       
      this._http.post('http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token', 
        params.toString(), { headers: headers })
        .subscribe(
          data => this.saveToken(data),
          err => alert('Invalid Credentials')); 
  }

  saveToken(token) {
    var expireDate = new Date().getTime() + (1000 * token.expires_in);
    Cookie.set("access_token", token.access_token, expireDate);
    console.log('Obtained Access token');
    window.location.href = 'http://localhost:8089';
  }

  getResource(resourceUrl) : Observable<any> {
    var headers = new HttpHeaders({
      'Content-type': 'application/x-www-form-urlencoded; charset=utf-8', 
      'Authorization': 'Bearer '+Cookie.get('access_token')});
    return this._http.get(resourceUrl, { headers: headers })
                   .catch((error:any) => Observable.throw(error.json().error || 'Server error'));
  }

  checkCredentials() {
    return Cookie.check('access_token');
  } 

  logout() {
    Cookie.delete('access_token');
    window.location.reload();
  }
}

Хранение файлов cookie здесь особенно важно, потому что мы используем файлы cookie только для целей хранения, а не для непосредственного управления процессом аутентификации. Это помогает защититься от атак и уязвимостей с подделкой межсайтовых запросов (CSRF).

5.3. Компонент Foo

Наконец, наш FooComponent для отображения подробностей о Foo:

5.5. App Component

@Component({
  selector: 'foo-details',
  providers: [AppService],  
  template: `<div class="container">
    <h1 class="col-sm-12">Foo Details</h1>
    <div class="col-sm-12">
        <label class="col-sm-3">ID</label> <span>{{foo.id}}</span>
    </div>
    <div class="col-sm-12">
        <label class="col-sm-3">Name</label> <span>{{foo.name}}</span>
    </div>
    <div class="col-sm-12">
        <button class="btn btn-primary" (click)="getFoo()" type="submit">New Foo</button>        
    </div>
  </div>`
})

export class FooComponent {
  public foo = new Foo(1,'sample foo');
  private foosUrl = 'http://localhost:8081/resource-server/api/foos/';  

  constructor(private _service:AppService) {}

  getFoo() {
    this._service.getResource(this.foosUrl+this.foo.id)
      .subscribe(
         data => this.foo = data,
         error =>  this.foo.name = 'Error');
    }
}

Наш простой AppComponent, который будет выступать в качестве корневого компонента:

И AppModule, куда мы помещаем все наши компоненты, сервисы и маршруты:

@Component({
  selector: 'app-root',
  template: `<nav class="navbar navbar-default">
    <div class="container-fluid">
      <div class="navbar-header">
        <a class="navbar-brand" href="/">Spring Security Oauth - Authorization Code</a>
      </div>
    </div>
  </nav>
  <router-outlet></router-outlet>`
})

export class AppComponent { }

7. Запуск внешнего интерфейса

@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    FooComponent    
  ],
  imports: [
    BrowserModule,
    HttpClientModule,
    RouterModule.forRoot([
     { path: '', component: HomeComponent, pathMatch: 'full' }], {onSameUrlNavigation: 'reload'})
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

1. Чтобы запустить любой из наших интерфейсных модулей, нам нужно сначала собрать приложение:

2. Затем нам нужно перейти в каталог нашего приложения Angular:

mvn clean install

3. Наконец, мы запустит наше приложение:

cd src/main/resources

Сервер по умолчанию запустится на порту 4200; чтобы изменить порт любого модуля, измените:

npm start

в package.json; например, чтобы запустить его на порту 8089, добавьте:

"start": "ng serve"

8. Заключение

"start": "ng serve --port 8089"

В этой статье мы узнали, как авторизовать наше приложение с помощью OAuth2.

Полную реализацию этого руководства можно найти в проекте GitHub.

«