Java tutorial
package com.wadpam.guja.oauth2.api; /* * #%L * guja-core * %% * Copyright (C) 2014 Wadpam * %% * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public * License along with this program. If not, see * <http://www.gnu.org/licenses/gpl-3.0.html>. * #L% */ import com.google.common.collect.ImmutableMap; import com.google.inject.Inject; import com.google.inject.name.Named; import com.wadpam.guja.exceptions.BadRequestRestException; import com.wadpam.guja.exceptions.InternalServerErrorRestException; import com.wadpam.guja.oauth2.api.requests.RefreshTokenRequest; import com.wadpam.guja.oauth2.api.requests.RevocationRequest; import com.wadpam.guja.oauth2.api.requests.UserCredentials; import com.wadpam.guja.oauth2.dao.DConnectionDaoBean; import com.wadpam.guja.oauth2.dao.DConnectionMapper; import com.wadpam.guja.oauth2.domain.DConnection; import com.wadpam.guja.oauth2.domain.DOAuth2User; import com.wadpam.guja.oauth2.provider.TokenGenerator; import com.wadpam.guja.oauth2.provider.UserAuthenticationProvider; import com.wadpam.guja.oauth2.web.JsonCharacterEncodingReponseFilter; import com.wadpam.guja.oauth2.web.OAuth2Filter; import com.wadpam.guja.oauth2.web.Oauth2ClientAuthenticationFilter; import org.joda.time.DateTime; import org.joda.time.Seconds; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.security.PermitAll; import javax.inject.Singleton; import javax.ws.rs.*; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.NewCookie; import javax.ws.rs.core.Response; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Date; import static com.google.common.base.Preconditions.checkNotNull; /** * An oauth2 implementation support the Resource Owner Password Credential Grant flow. * http://tools.ietf.org/html/rfc6749#section-4.3 * This flow is typically used by trusted client that my touch the resource owners (user) credentials. * * @author mattiaslevin * @author sosandstrom */ @Path("oauth") @Singleton @PermitAll @Produces(JsonCharacterEncodingReponseFilter.APPLICATION_JSON_UTF8) public class OAuth2AuthorizationResource { private static final Logger LOGGER = LoggerFactory.getLogger(OAuth2AuthorizationResource.class); private static final boolean ALWAYS_REVOKE_REFRESH_TOKEN = true; private static final int DEFAULT_EXPIRES_IN = 60 * 60 * 24 * 7; // 1 week private static final String PASSWORD_GRANT_TYPE = "password"; private static final String REFRESH_TOKEN_GRANT_TYPE = "refresh_token"; private static final String TOKEN_TYPE_BEARER = "Bearer"; private final DConnectionDaoBean connectionDao; private final TokenGenerator accessTokenGenerator; private final UserAuthenticationProvider authenticationProvider; private int tokenExpiresIn = DEFAULT_EXPIRES_IN; @Inject public OAuth2AuthorizationResource(UserAuthenticationProvider authenticationProvider, TokenGenerator accessTokenGenerator, DConnectionDaoBean connectionDao) { this.connectionDao = connectionDao; this.authenticationProvider = authenticationProvider; this.accessTokenGenerator = accessTokenGenerator; } /** * Authorize a user (basically a sign in) * * @param credentials user credentials * @return access_token and refresh token if successful. * Success response http://tools.ietf.org/html/rfc6749#section-5.1 * Failure response http://tools.ietf.org/html/rfc6749#section-5.2 */ @POST @Path("authorize") @Consumes(MediaType.APPLICATION_JSON) public Response authorize(UserCredentials credentials) { // Perform all validation here to control the exact error message returned to comply with the Oauth2 standard // The client has been authenticated already in the interceptor if (!PASSWORD_GRANT_TYPE.equals(credentials.getGrant_type())) { // Unsupported grant type throw new BadRequestRestException( ImmutableMap.of("error", Oauth2ClientAuthenticationFilter.ERROR_UNSUPPORTED_GRANT_TYPE)); } if (null == credentials.getUsername() || null == credentials.getPassword()) { throw new BadRequestRestException( ImmutableMap.of("error", Oauth2ClientAuthenticationFilter.ERROR_INVALID_REQUEST)); } DOAuth2User oauth2User = authenticationProvider.authenticate(credentials.getUsername(), credentials.getPassword()); if (null != oauth2User) { // load connection from db async style (likely case is new token for existing user) final Iterable<DConnection> existingConnections = connectionDao .queryByProviderUserId(oauth2User.getId().toString()); DConnection connection = generateConnection(oauth2User, null, null); connectionDao.putWithCacheKey(connection.getAccessToken(), connection); // Remove expired connections for the user removeExpiredConnections(FactoryResource.PROVIDER_ID_SELF, existingConnections); return Response .ok(ImmutableMap.builder().put("access_token", connection.getAccessToken()) .put("refresh_token", connection.getRefreshToken()).put("expires_in", tokenExpiresIn) .put("token_type", TOKEN_TYPE_BEARER).build()) .cookie(createCookie(connection.getAccessToken(), tokenExpiresIn)).build(); } else { // authentication failed throw new BadRequestRestException( ImmutableMap.of("error", Oauth2ClientAuthenticationFilter.ERROR_INVALID_GRANT)); } } @POST @Path("authorize") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) public Response authorize(@FormParam("client_id") String clientId, @FormParam("client_secret") String clientSecret, @FormParam("username") String username, @FormParam("password") String password, @FormParam("grant_type") String grantType) { UserCredentials credentials = new UserCredentials(); credentials.setClient_id(clientId); credentials.setClient_secret(clientSecret); credentials.setUsername(username); credentials.setPassword(password); credentials.setGrant_type(grantType); return authorize(credentials); } private DConnection generateConnection(DOAuth2User oauth2User, String profileUrl, String imageUrl) { return DConnectionMapper.newBuilder().accessToken(accessTokenGenerator.generate()) .refreshToken(accessTokenGenerator.generate()).providerId(FactoryResource.PROVIDER_ID_SELF) .providerUserId(oauth2User.getId().toString()).userId(oauth2User.getId()) .expireTime(calculateExpirationDate(tokenExpiresIn)).userRoles(convertRoles(oauth2User.getRoles())) .displayName(oauth2User.getDisplayName()).imageUrl(imageUrl).profileUrl(profileUrl).build(); } private Date calculateExpirationDate(int expiresInSeconds) { return DateTime.now().plusSeconds(expiresInSeconds).toDate(); } private NewCookie createCookie(String accessToken, int expiresInSeconds) { return new NewCookie(OAuth2Filter.NAME_ACCESS_TOKEN, accessToken, "/api", null, null, expiresInSeconds, false); } /** * Refresh an access_token using the refresh token * * @param refreshToken refresh token * @return @return access_token and refresh token if successful. * Success response http://tools.ietf.org/html/rfc6749#section-5.1 * Failure response http://tools.ietf.org/html/rfc6749#section-5.2 */ @POST @Path("refresh") @Consumes(MediaType.APPLICATION_JSON) public Response refreshAccessToken(RefreshTokenRequest refreshToken) { // Perform all validation here to control the exact error message returned to comply with the Oauth2 standard if (null == refreshToken.getRefresh_token() || null == refreshToken.getGrant_type()) { throw new BadRequestRestException(ImmutableMap.of("error", "invalid_request")); } if (!REFRESH_TOKEN_GRANT_TYPE.equals(refreshToken.getGrant_type())) { // Unsupported grant type throw new BadRequestRestException(ImmutableMap.of("error", "unsupported_grant_type")); } DConnection connection = connectionDao.findByRefreshToken(refreshToken.getRefresh_token()); if (null == connection) { throw new BadRequestRestException(ImmutableMap.of("error", "invalid_grant")); } // Invalidate the old cache key connectionDao.invalidateCacheKey(connection.getAccessToken()); connection.setAccessToken(accessTokenGenerator.generate()); connection.setExpireTime(calculateExpirationDate(tokenExpiresIn)); connectionDao.putWithCacheKey(connection.getAccessToken(), connection); return Response.ok(ImmutableMap.builder().put("access_token", connection.getAccessToken()) .put("refresh_token", connection.getRefreshToken()).put("expires_in", tokenExpiresIn).build()) .cookie(createCookie(connection.getAccessToken(), tokenExpiresIn)).build(); } @POST @Path("refresh") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) public Response refreshAccessToken(@FormParam("client_id") String clientId, @FormParam("client_secret") String clientSecret, @FormParam("refresh_token") String refreshToken, @FormParam("grant_type") String grantType) { RefreshTokenRequest refreshTokenRequest = new RefreshTokenRequest(); refreshTokenRequest.setClient_id(clientId); refreshTokenRequest.setClient_secret(clientSecret); refreshTokenRequest.setRefresh_token(refreshToken); refreshTokenRequest.setGrant_type(grantType); return refreshAccessToken(refreshTokenRequest); } /** * Revoke a users access_token and refresh_token. * https://tools.ietf.org/html/rfc7009 * * @param revocationRequest contains either the access token or refresh token and a token type hint * @return will always return http 200 */ @POST @Path("revoke") @Consumes(MediaType.APPLICATION_JSON) public Response revoke(RevocationRequest revocationRequest) { // Perform all validation here to control the exact error message returned to comply with the Oauth2 standard String token = revocationRequest.getToken(); String tokenHint = revocationRequest.getToken_type_hint(); if (null != token) { // Start with the token type hint but always widen the scope if not found if (null == tokenHint || "access_token".equals(tokenHint)) { if (!revokeAccessToken(token)) { revokeRefreshToken(token); } } else { if (!revokeRefreshToken(token)) { revokeAccessToken(token); } } } // Always send http 200 according to the specification return Response.ok().build(); } @POST @Path("revoke") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) public Response revoke(@FormParam("client_id") String clientId, @FormParam("client_secret") String clientSecret, @FormParam("token") String token, @FormParam("token_type_hint") String tokenTypeHint) { RevocationRequest revocationRequest = new RevocationRequest(); revocationRequest.setClient_id(clientId); revocationRequest.setClient_secret(clientSecret); revocationRequest.setToken(token); revocationRequest.setToken_type_hint(tokenTypeHint); return revoke(revocationRequest); } private boolean revokeAccessToken(String token) { DConnection connection = connectionDao.findByAccessToken(token); if (null != connection) { if (ALWAYS_REVOKE_REFRESH_TOKEN) { // Delete the connection completely connectionDao.deleteWithCacheKey(connection.getAccessToken(), connection.getId()); } else { // Invalidate old cache key connectionDao.invalidateCacheKey(connection.getAccessToken()); // Remove the access_token // Still allow the user to refresh using the refresh token connection.setAccessToken(null); try { // Do not cache, access token is null connectionDao.put(connection); } catch (IOException e) { LOGGER.error("Failed to update connection {}", e); throw new InternalServerErrorRestException("failed to update connection"); } } return true; } else { return false; } } private boolean revokeRefreshToken(String token) { DConnection connection = connectionDao.findByRefreshToken(token); if (null != connection) { // Delete the connection completely connectionDao.deleteWithCacheKey(connection.getAccessToken(), connection.getId()); return true; } else { return false; } } /** * Validate an access_token. * The Oauth2 specification does not specify how this should be done. Do similar to what Google does * * @param access_token access token to validate. Be careful about using url safe tokens or use url encoding. * @return http 200 if success and some basic info about the access_token */ @GET @Path("tokeninfo") public Response validate(@QueryParam("access_token") String access_token) { checkNotNull(access_token); DConnection connection = connectionDao.findByAccessToken(access_token); LOGGER.debug("Connection {}", connection); if (null == connection || hasAccessTokenExpired(connection)) { throw new BadRequestRestException("Invalid access_token"); } return Response.ok(ImmutableMap .builder().put("user_id", connection.getUserId()).put("expires_in", Seconds .secondsBetween(DateTime.now(), new DateTime(connection.getExpireTime())).getSeconds()) .build()).build(); } private boolean hasAccessTokenExpired(DConnection connection) { return new DateTime(connection.getExpireTime()).isBeforeNow(); } /** * Remove cookie from the user agent. * * @return Redirect to requests * @throws URISyntaxException */ @GET @Path("logout") public Response logout() throws URISyntaxException { return Response.temporaryRedirect(new URI("/")).cookie(createCookie(null, 0)).build(); } private void removeExpiredConnections(String providerId, Iterable<DConnection> connections) { // find other connections for this user, discard expired final ArrayList<Long> expiredTokens = new ArrayList<Long>(); for (DConnection dc : connections) { if (providerId.equals(dc.getProviderId())) { // expired? only remove if no refresh token if (null == dc.getRefreshToken() && null != dc.getExpireTime() && hasAccessTokenExpired(dc)) { expiredTokens.add(dc.getId()); } } } try { // Do not invalidate the cache, they are harmless since expired and they will get evicted over time connectionDao.delete(expiredTokens); } catch (IOException e) { LOGGER.error("Failed to delete expired tokens {}", e); throw new InternalServerErrorRestException("Failed to delete expired tokens"); } } public static String convertRoles(Iterable<String> from) { if (null == from) { return null; } final StringBuffer to = new StringBuffer(); boolean first = true; for (String s : from) { if (!first) { to.append(DConnection.ROLE_SEPARATOR); } to.append(s.trim()); first = false; } return to.toString(); } @Inject(optional = true) public void setTokenExpiresIn(@Named("app.oauth.tokenExpiresIn") int tokenExpiresIn) { this.tokenExpiresIn = tokenExpiresIn; } }