1. Обзор
В этом руководстве мы защитим REST API с помощью OAuth2 и используем его из простого клиента Angular.
Приложение, которое мы собираемся создать, будет состоять из трех отдельных модулей:
- Сервер авторизации
- Сервер ресурсов
- Код авторизации пользовательского интерфейса: интерфейсное приложение, использующее поток кода авторизации.
Мы будем использовать стек OAuth в Spring Security 5. Если вы хотите использовать устаревший стек Spring Security OAuth, ознакомьтесь с этой предыдущей статьей: Spring REST API + OAuth2 + Angular (использование стека Spring Security OAuth Legacy) .
Давайте прыгать прямо в.
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/foreach
jwk-set-uri: http://localhost:8083/auth/realms/foreach/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
}
Затем нам нужен репозиторий Foo
s. Мы будем использовать 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/foreach/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
, который содержит логику для взаимодействия с сервером:
retrieveToken()
: для получения токена доступа с использованием кода авторизацииsaveToken()
: чтобы сохранить наш токен доступа в файле cookie с использованием библиотеки ng2-cookies.getResource()
: получить объект Foo с сервера, используя его идентификаторcheckCredentials()
: чтобы проверить, вошел ли пользователь в систему или нетlogout()
: удалить файл 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/foreach/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();
}
}
В методе retrieveToken
мы используем наши учетные данные клиента и базовую аутентификацию для отправки POST
в конечную точку /openid-connect/token
для получения токена доступа. Параметры передаются в формате URL-кодировки. После получения токена доступа мы сохраняем его в файле cookie.
Хранение файлов cookie здесь особенно важно, потому что мы используем файлы cookie только для целей хранения, а не для непосредственного управления процессом аутентификации. Это помогает защититься от атак и уязвимостей с подделкой межсайтовых запросов (CSRF).
5.3. Фу Компонент
Наконец, наш FooComponent
для отображения деталей Foo:
@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');
}
}
5.5. Компонент приложения
Наш простой AppComponent
выступает в роли корневого компонента:
@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 { }
И AppModule
, куда мы упаковываем все наши компоненты, сервисы и маршруты:
@NgModule({
declarations: [
AppComponent,
HomeComponent,
FooComponent
],
imports: [
BrowserModule,
HttpClientModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' }], {onSameUrlNavigation: 'reload'})
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
7. Запустите внешний интерфейс
- Чтобы запустить любой из наших интерфейсных модулей, нам нужно сначала собрать приложение:
mvn clean install
- Затем нам нужно перейти в каталог нашего приложения Angular:
cd src/main/resources
- Наконец, мы запустим наше приложение:
npm start
Сервер запустится по умолчанию на порту 4200; чтобы изменить порт любого модуля, измените:
"start": "ng serve"
в пакете.json;
например, чтобы он работал на порту 8089, добавьте:
"start": "ng serve --port 8089"
8. Заключение
В этой статье мы узнали, как авторизовать наше приложение с помощью OAuth2.
Полную реализацию этого туториала можно найти в проекте GitHub .