Jak działa uwierzytelnianie oparte na tokenach
W uwierzytelnianiu opartym na tokenach klient wymienia twarde dane uwierzytelniające (takie jak nazwa użytkownika i hasło) na kawałek danych o nazwie token . Dla każdego żądania, zamiast wysyłać twarde dane uwierzytelniające, klient wyśle token do serwera w celu wykonania uwierzytelnienia, a następnie autoryzacji.
W kilku słowach schemat uwierzytelniania oparty na tokenach wykonuje następujące kroki:
- Klient wysyła swoje poświadczenia (nazwę użytkownika i hasło) na serwer.
- Serwer uwierzytelnia poświadczenia i, jeśli są one prawidłowe, wygeneruje token dla użytkownika.
- Serwer przechowuje poprzednio wygenerowany token w pewnej pamięci wraz z identyfikatorem użytkownika i datą ważności.
- Serwer wysyła wygenerowany token do klienta.
- Klient wysyła token do serwera w każdym żądaniu.
- Serwer w każdym żądaniu wyodrębnia token z przychodzącego żądania. Za pomocą tokena serwer wyszukuje dane użytkownika w celu przeprowadzenia uwierzytelnienia.
- Jeśli token jest prawidłowy, serwer akceptuje żądanie.
- Jeśli token jest nieprawidłowy, serwer odrzuca żądanie.
- Po przeprowadzeniu uwierzytelnienia serwer wykonuje autoryzację.
- Serwer może zapewnić punkt końcowy do odświeżania tokenów.
Uwaga: Krok 3 nie jest wymagany, jeśli serwer wydał podpisany token (taki jak JWT, który umożliwia przeprowadzanie uwierzytelniania bezstanowego ).
Co możesz zrobić z JAX-RS 2.0 (Jersey, RESTEasy i Apache CXF)
To rozwiązanie wykorzystuje tylko interfejs API JAX-RS 2.0, co pozwala uniknąć rozwiązań specyficznych dla dostawcy . Powinien więc współpracować z implementacjami JAX-RS 2.0, takimi jak Jersey , RESTEasy i Apache CXF .
Warto wspomnieć, że jeśli używasz uwierzytelniania opartego na tokenach, nie polegasz na standardowych mechanizmach bezpieczeństwa aplikacji sieci Web Java EE oferowanych przez kontener serwletu i konfigurowalnych za pomocą web.xml
deskryptora aplikacji . To niestandardowe uwierzytelnianie.
Uwierzytelnianie użytkownika za pomocą nazwy użytkownika i hasła oraz wydawanie tokena
Utwórz metodę zasobu JAX-RS, która odbiera i sprawdza poświadczenia (nazwę użytkownika i hasło) i wydaje token dla użytkownika:
@Path("/authentication")
public class AuthenticationEndpoint {
@POST
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response authenticateUser(@FormParam("username") String username,
@FormParam("password") String password) {
try {
// Authenticate the user using the credentials provided
authenticate(username, password);
// Issue a token for the user
String token = issueToken(username);
// Return the token on the response
return Response.ok(token).build();
} catch (Exception e) {
return Response.status(Response.Status.FORBIDDEN).build();
}
}
private void authenticate(String username, String password) throws Exception {
// Authenticate against a database, LDAP, file or whatever
// Throw an Exception if the credentials are invalid
}
private String issueToken(String username) {
// Issue a token (can be a random String persisted to a database or a JWT token)
// The issued token must be associated to a user
// Return the issued token
}
}
Jeśli podczas sprawdzania poświadczeń zostaną zgłoszone wyjątki, 403
zostanie zwrócona odpowiedź o statusie (Zabronione).
Jeśli poświadczenia zostaną pomyślnie zweryfikowane, odpowiedź ze statusem 200
(OK) zostanie zwrócona, a wydany token zostanie wysłany do klienta w ładunku odpowiedzi. Klient musi wysyłać token do serwera przy każdym żądaniu.
Podczas konsumpcji application/x-www-form-urlencoded
klient musi wysłać poświadczenia w następującym formacie w ładunku żądania:
username=admin&password=123456
Zamiast parametrów formularza możliwe jest zawinięcie nazwy użytkownika i hasła w klasę:
public class Credentials implements Serializable {
private String username;
private String password;
// Getters and setters omitted
}
A następnie skonsumuj jako JSON:
@POST
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public Response authenticateUser(Credentials credentials) {
String username = credentials.getUsername();
String password = credentials.getPassword();
// Authenticate the user, issue a token and return a response
}
Korzystając z tego podejścia, klient musi wysłać poświadczenia w następującym formacie w ładunku żądania:
{
"username": "admin",
"password": "123456"
}
Wydobywanie tokena z żądania i sprawdzanie jego poprawności
Klient powinien wysłać token w standardowym Authorization
nagłówku HTTP żądania. Na przykład:
Authorization: Bearer <token-goes-here>
Nazwa standardowego nagłówka HTTP jest niefortunna, ponieważ przenosi informacje uwierzytelniające , a nie autoryzację . Jest to jednak standardowy nagłówek HTTP do wysyłania poświadczeń na serwer.
JAX-RS zapewnia @NameBinding
meta-adnotację służącą do tworzenia innych adnotacji w celu powiązania filtrów i przechwytywaczy z klasami i metodami zasobów. Zdefiniuj @Secured
adnotację w następujący sposób:
@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured { }
Powyżej zdefiniowana adnotacja wiążąca nazwę zostanie użyta do udekorowania klasy filtru, która implementuje się ContainerRequestFilter
, umożliwiając przechwycenie żądania przed przetworzeniem przez metodę zasobów. ContainerRequestContext
Może być używany do uzyskania dostępu do nagłówków HTTP, a następnie wyodrębnić token:
@Secured
@Provider
@Priority(Priorities.AUTHENTICATION)
public class AuthenticationFilter implements ContainerRequestFilter {
private static final String REALM = "example";
private static final String AUTHENTICATION_SCHEME = "Bearer";
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
// Get the Authorization header from the request
String authorizationHeader =
requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
// Validate the Authorization header
if (!isTokenBasedAuthentication(authorizationHeader)) {
abortWithUnauthorized(requestContext);
return;
}
// Extract the token from the Authorization header
String token = authorizationHeader
.substring(AUTHENTICATION_SCHEME.length()).trim();
try {
// Validate the token
validateToken(token);
} catch (Exception e) {
abortWithUnauthorized(requestContext);
}
}
private boolean isTokenBasedAuthentication(String authorizationHeader) {
// Check if the Authorization header is valid
// It must not be null and must be prefixed with "Bearer" plus a whitespace
// The authentication scheme comparison must be case-insensitive
return authorizationHeader != null && authorizationHeader.toLowerCase()
.startsWith(AUTHENTICATION_SCHEME.toLowerCase() + " ");
}
private void abortWithUnauthorized(ContainerRequestContext requestContext) {
// Abort the filter chain with a 401 status code response
// The WWW-Authenticate header is sent along with the response
requestContext.abortWith(
Response.status(Response.Status.UNAUTHORIZED)
.header(HttpHeaders.WWW_AUTHENTICATE,
AUTHENTICATION_SCHEME + " realm=\"" + REALM + "\"")
.build());
}
private void validateToken(String token) throws Exception {
// Check if the token was issued by the server and if it's not expired
// Throw an Exception if the token is invalid
}
}
Jeśli podczas sprawdzania tokena 401
wystąpią jakiekolwiek problemy, zostanie zwrócona odpowiedź o statusie (Nieautoryzowane). W przeciwnym razie żądanie przejdzie do metody zasobu.
Zabezpieczanie punktów końcowych REST
Aby powiązać filtr uwierzytelniania z metodami zasobów lub klasami zasobów, opatrz je @Secured
adnotacjami utworzonymi powyżej. Dla metod i / lub klas, które są opatrzone adnotacjami, filtr zostanie wykonany. Oznacza to, że takie punkty końcowe zostaną osiągnięte tylko wtedy, gdy żądanie zostanie wykonane przy użyciu ważnego tokena.
Jeśli niektóre metody lub klasy nie wymagają uwierzytelnienia, nie dodawaj adnotacji:
@Path("/example")
public class ExampleResource {
@GET
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response myUnsecuredMethod(@PathParam("id") Long id) {
// This method is not annotated with @Secured
// The authentication filter won't be executed before invoking this method
...
}
@DELETE
@Secured
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response mySecuredMethod(@PathParam("id") Long id) {
// This method is annotated with @Secured
// The authentication filter will be executed before invoking this method
// The HTTP request must be performed with a valid token
...
}
}
W powyższym przykładzie filtr zostanie wykonany tylko dla mySecuredMethod(Long)
metody, ponieważ jest opatrzony adnotacją @Secured
.
Identyfikacja bieżącego użytkownika
Jest bardzo prawdopodobne, że będziesz musiał znać użytkownika, który wykonuje żądanie, również w interfejsie API REST. Aby to osiągnąć, można zastosować następujące podejścia:
Przesłanianie kontekstu bezpieczeństwa bieżącego żądania
W ramach tej ContainerRequestFilter.filter(ContainerRequestContext)
metody SecurityContext
można ustawić nową instancję dla bieżącego żądania. Następnie zastąp SecurityContext.getUserPrincipal()
, zwracając Principal
instancję:
final SecurityContext currentSecurityContext = requestContext.getSecurityContext();
requestContext.setSecurityContext(new SecurityContext() {
@Override
public Principal getUserPrincipal() {
return () -> username;
}
@Override
public boolean isUserInRole(String role) {
return true;
}
@Override
public boolean isSecure() {
return currentSecurityContext.isSecure();
}
@Override
public String getAuthenticationScheme() {
return AUTHENTICATION_SCHEME;
}
});
Użyj tokena, aby wyszukać identyfikator użytkownika (nazwę użytkownika), który będzie Principal
nazwą.
Wstrzyknąć SecurityContext
dowolną klasę zasobów JAX-RS:
@Context
SecurityContext securityContext;
To samo można zrobić w metodzie zasobów JAX-RS:
@GET
@Secured
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public Response myMethod(@PathParam("id") Long id,
@Context SecurityContext securityContext) {
...
}
A następnie uzyskaj Principal
:
Principal principal = securityContext.getUserPrincipal();
String username = principal.getName();
Korzystanie z CDI (wstrzykiwanie kontekstu i zależności)
Jeśli z jakiegoś powodu nie chcesz przesłonić SecurityContext
, możesz użyć CDI (wstrzykiwanie kontekstu i zależności), które zapewnia przydatne funkcje, takie jak zdarzenia i producenci.
Utwórz kwalifikator CDI:
@Qualifier
@Retention(RUNTIME)
@Target({ METHOD, FIELD, PARAMETER })
public @interface AuthenticatedUser { }
W AuthenticationFilter
utworzonym powyżej wstrzyknij Event
adnotację @AuthenticatedUser
:
@Inject
@AuthenticatedUser
Event<String> userAuthenticatedEvent;
Jeśli uwierzytelnienie się powiedzie, uruchom zdarzenie przekazujące nazwę użytkownika jako parametr (pamiętaj, że token jest wydawany użytkownikowi, a token zostanie użyty do wyszukania identyfikatora użytkownika):
userAuthenticatedEvent.fire(username);
Jest bardzo prawdopodobne, że w twojej aplikacji istnieje klasa reprezentująca użytkownika. Nazwijmy tę klasę User
.
Utwórz komponent bean CDI do obsługi zdarzenia uwierzytelnienia, znajdź User
instancję z odpowiednią nazwą użytkownika i przypisz ją do authenticatedUser
pola producenta:
@RequestScoped
public class AuthenticatedUserProducer {
@Produces
@RequestScoped
@AuthenticatedUser
private User authenticatedUser;
public void handleAuthenticationEvent(@Observes @AuthenticatedUser String username) {
this.authenticatedUser = findUser(username);
}
private User findUser(String username) {
// Hit the the database or a service to find a user by its username and return it
// Return the User instance
}
}
authenticatedUser
Pola powoduje User
wystąpienie, które mogą być wstrzykiwane do kontenerów udało ziaren, takich jak usługi JAX-RS, CDI fasoli, serwletów i EJB. Użyj poniższego kodu, aby wstrzyknąć User
instancję (w rzeczywistości jest to proxy CDI):
@Inject
@AuthenticatedUser
User authenticatedUser;
Uwaga: @Produces
adnotacja CDI różni się od @Produces
adnotacji JAX-RS :
Upewnij się, że używasz @Produces
adnotacji CDI w swojej AuthenticatedUserProducer
fasoli.
Kluczem jest tutaj fasola opatrzona adnotacjami @RequestScoped
, umożliwiająca współdzielenie danych między filtrami a ziarnami. Jeśli nie chcesz używać zdarzeń, możesz zmodyfikować filtr, aby przechowywać uwierzytelnionego użytkownika w komponencie bean o zasięgu żądania, a następnie odczytać go z klas zasobów JAX-RS.
W porównaniu z podejściem, które zastępuje podejście SecurityContext
, podejście CDI pozwala uzyskać uwierzytelnionego użytkownika z komponentów bean innych niż zasoby i dostawcy JAX-RS.
Obsługa autoryzacji opartej na rolach
Proszę odnieść się do mojej drugiej odpowiedzi, aby uzyskać szczegółowe informacje na temat obsługi autoryzacji opartej na rolach.
Wydawanie tokenów
Tokenem może być:
- Nieprzezroczysty: nie ujawnia żadnych szczegółów poza samą wartością (jak ciąg losowy)
- Samodzielny: zawiera szczegółowe informacje o samym tokenie (np. JWT).
Szczegóły poniżej:
Losowy ciąg jako token
Token można wydać, generując losowy ciąg znaków i utrwalając go w bazie danych wraz z identyfikatorem użytkownika i datą ważności. Dobry przykład generowania losowego ciągu znaków w Javie można zobaczyć tutaj . Możesz także użyć:
Random random = new SecureRandom();
String token = new BigInteger(130, random).toString(32);
JWT (token sieciowy JSON)
JWT (JSON Web Token) to standardowa metoda bezpiecznego reprezentowania roszczeń między dwiema stronami i jest zdefiniowana w RFC 7519 .
Jest to samodzielny token i umożliwia przechowywanie szczegółów w roszczeniach . Roszczenia te są przechowywane w ładunku tokena, który jest kodem JSON zakodowanym jako Base64 . Oto niektóre roszczenia zarejestrowane w RFC 7519 i ich znaczenie (przeczytaj pełne RFC w celu uzyskania dalszych szczegółów):
iss
: Zleceniodawca, który wydał token.
sub
: Zleceniodawca będący przedmiotem JWT.
exp
: Data ważności tokena.
nbf
: Czas, w którym token zacznie być akceptowany do przetwarzania.
iat
: Czas, w którym token został wydany.
jti
: Unikalny identyfikator tokena.
Pamiętaj, że nie możesz przechowywać poufnych danych, takich jak hasła, w tokenie.
Klient może odczytać ładunek, a integralność tokena można łatwo sprawdzić, weryfikując jego podpis na serwerze. Podpis zapobiega manipulowaniu tokenem.
Nie musisz utrwalać tokenów JWT, jeśli nie musisz ich śledzić. Chociaż utrzymując tokeny, będziesz mieć możliwość unieważnienia i cofnięcia dostępu do nich. Aby śledzić tokeny JWT, zamiast utrwalać cały token na serwerze, możesz zachować identyfikator tokena ( jti
roszczenie) wraz z innymi szczegółami, takimi jak użytkownik, dla którego wystawiłeś token, data ważności itp.
Podczas utrwalania tokenów zawsze należy rozważyć usunięcie starych, aby zapobiec nieograniczonemu rozwojowi bazy danych.
Korzystanie z JWT
Istnieje kilka bibliotek Java do wydawania i sprawdzania poprawności tokenów JWT, takich jak:
Aby znaleźć inne świetne zasoby do pracy z JWT, zajrzyj na http://jwt.io .
Obsługa odwołania tokenu za pomocą JWT
Jeśli chcesz odwołać tokeny, musisz je śledzić. Nie musisz przechowywać całego tokena po stronie serwera, przechowywać tylko identyfikator tokena (który musi być unikalny) i niektóre metadane, jeśli potrzebujesz. Jako identyfikator tokena możesz użyć UUID .
jti
Roszczenia powinny być wykorzystywane do przechowywania identyfikatora tokena na token. Podczas sprawdzania poprawności tokena upewnij się, że nie został on odwołany, sprawdzając wartość jti
roszczenia względem identyfikatorów tokena, które posiadasz po stronie serwera.
Ze względów bezpieczeństwa odwołaj wszystkie tokeny dla użytkownika, gdy zmieni hasło.
Dodatkowe informacje
- Nie ma znaczenia, jakiego rodzaju uwierzytelnienia zdecydujesz się użyć. Zawsze rób to na połączeniu HTTPS, aby zapobiec atakowi typu man-in-the-middle .
- Spójrz na to pytanie z działu Bezpieczeństwa Informacji, aby uzyskać więcej informacji na temat tokenów.
- W tym artykule znajdziesz przydatne informacje na temat uwierzytelniania opartego na tokenach.
The server stores the previously generated token in some storage along with the user identifier and an expiration date. The server sends the generated token to the client.
Jak to jest RESTful?