class JWTSecurityConfigurationEditForm(AdminEditForm): """JWT security configuration edit form""" title = _("Security manager") legend = _("JWT configuration") fields = Fields(IJWTSecurityConfiguration).select('access_token_name', 'refresh_token_name') def get_content(self): return IJWTSecurityConfiguration(self.context) def apply_changes(self, data): configuration = self.get_content() old_region = configuration.selected_cache changes = super(JWTSecurityConfigurationEditForm, self).apply_changes(data) if changes and (old_region is not None): clear_cache(JWT_PROXY_CACHE_NAME, old_region, JWT_PROXY_TOKENS_NAMESPACE) return changes
class JWTConfigurationHeader(AlertMessage): """JWT configuration header""" status = 'info' _message = _("JWT authentication module \"local mode\" allows to generate, check and refresh " "tokens locally.\n" "You can choose to use a simple secret key to encrypt your tokens, or to " "use a private and public keys pair (which can to be used to share tokens " "between two applications).")
class JWTProxyConfigurationHeader(AlertMessage): """JWT proxy configuration header""" status = 'info' _message = _("JWT authentication module \"proxy mode\" relies on another authentication " "authority (which can be another application using this JWT package) to " "generate, check and refresh tokens. This authority can be used to share " "access tokens between different applications.\n" "You can cache tokens to reduce the number of requests which will be forwarded " "to the authentication authority.")
class JWTConfigurationKeyAlert(AlertMessage): """JWT configuration keys alert""" status = 'info' css_class = 'mb-1 p-2' _message = _("""You can use the `openssl` command to generate your keys: openssl genpkey -algorithm RSA -out private-key.pem openssl rsa -pubout -in private-key.pem -out public-key.pem """) message_renderer = 'markdown'
def check_configuration(self): """Check for JWT configuration""" if self.local_mode and self.proxy_mode: raise Invalid(_("You can't enable both local and proxy modes")) if self.local_mode: if not self.algorithm: raise Invalid( _("You must choose an algorithm to enable JWT authentication" )) if self.algorithm.startswith('HS'): # pylint: disable=no-member if not self.secret: raise Invalid( _("You must define JWT secret to use HS256 algorithm")) elif self.algorithm.startswith('RS'): # pylint: disable=no-member if not (self.private_key and self.public_key): raise Invalid( _("You must define a private and a public key to use " "RS256 algorithm")) if self.proxy_mode: if not self.authority: raise Invalid( _("You must define authentication authority to use proxy mode" )) if self.use_cache and not self.selected_cache: raise Invalid( _("You must choose a cache to enable tokens caching"))
def get_jwt_token(request): """REST login endpoint for JWT authentication""" # check security manager utility sm = query_utility(ISecurityManager) # pylint: disable=invalid-name if sm is None: raise HTTPServiceUnavailable() configuration = IJWTSecurityConfiguration(sm) if not configuration.enabled: raise HTTPServiceUnavailable() # check request params params = request.params if TEST_MODE else request.validated login = params.get('login') if not login: raise HTTPBadRequest() credentials = Credentials('jwt', id=login, **params) # use remote authentication authority if configuration.proxy_mode: handler = IJWTProxyHandler(sm, None) if handler is not None: status_code, tokens = handler.get_tokens(request, credentials) # pylint: disable=assignment-from-no-return request.response.status_code = status_code return tokens # authenticate principal in security manager principal_id = sm.authenticate(credentials, request) if principal_id is not None: custom_claims = params.get('claims', {}) request.response.cache_expires(configuration.refresh_expiration) return { 'status': 'success', configuration.access_token_name: create_jwt_token(request, principal_id, expiration=configuration.access_expiration, obj=ACCESS_OBJECT, **custom_claims), configuration.refresh_token_name: create_jwt_token(request, principal_id, expiration=configuration.refresh_expiration, obj=REFRESH_OBJECT) } request.response.status_code = HTTPUnauthorized.code return { 'status': 'error', 'message': request.localizer.translate(_("Invalid credentials!")) }
class ClaimsSchema(ClaimsObjectSchema): """Token claims schema""" sub = SchemaNode(String(), title=_("Principal ID")) iat = SchemaNode(Int(), title=_("Token issue timestamp, in seconds")) exp = SchemaNode(Int(), title=_("Token expiration timestamp, in seconds"))
class ClaimsObjectSchema(MappingSchema): """Claims getter schema""" obj = SchemaNode(String(), title=_("Token object"), validator=OneOf((ACCESS_OBJECT, REFRESH_OBJECT)), missing=drop)
class TokensSchema(StatusSchema): """Tokens response schema""" accessToken = SchemaNode(String(), title=_("Access token")) refreshToken = SchemaNode(String(), title=_("Refresh token"))
class StatusSchema(MappingSchema): """Base status response schema""" status = SchemaNode(String(), title=_("Response status"), validator=OneOf(('success', 'error')))
class LoginSchema(ClaimsSetterSchema): """Login schema""" login = SchemaNode(String(), title=_("Login")) password = SchemaNode(String(), title=_("Password"))
class ErrorSchema(MappingSchema): """Base error schema""" status = SchemaNode(String(), title=_("Response status")) message = SchemaNode(String(), title=_("Error message"), missing=drop)
"""Claims getter schema""" obj = SchemaNode(String(), title=_("Token object"), validator=OneOf((ACCESS_OBJECT, REFRESH_OBJECT)), missing=drop) class ClaimsSchema(ClaimsObjectSchema): """Token claims schema""" sub = SchemaNode(String(), title=_("Principal ID")) iat = SchemaNode(Int(), title=_("Token issue timestamp, in seconds")) exp = SchemaNode(Int(), title=_("Token expiration timestamp, in seconds")) jwt_responses = { HTTPOk.code: TokensSchema(description=_("Tokens properties")), HTTPAccepted.code: StatusSchema(description=_("Token accepted")), HTTPNotFound.code: ErrorSchema(description=_("Page not found")), HTTPUnauthorized.code: ErrorSchema(description=_("Unauthorized")), HTTPForbidden.code: ErrorSchema(description=_("Forbidden access")), HTTPBadRequest.code: ErrorSchema(description=_("Missing arguments")), HTTPServiceUnavailable.code: ErrorSchema(description=_("Service unavailable")) } if TEST_MODE: service_params = {} else: service_params = {'response_schemas': jwt_responses} jwt_token = Service(name=REST_TOKEN_ROUTE,
class JWTSecurityConfiguration(NavigationMenuItem): """JWT security configuration menu""" label = _("JWT configuration") href = '#jwt-security-configuration.html'
class IJWTSecurityConfiguration(Interface): """Security manager configuration interface for JWT""" access_token_name = TextLine(title=_("Access token attribute"), description=_( "Name of the JSON attribute containing " "access token returned by REST APIs"), required=False, default=ACCESS_TOKEN_NAME) refresh_token_name = TextLine(title=_("Refresh token attribute"), description=_( "Name of the JSON attribute containing " "refresh token returned by REST APIs"), required=False, default=REFRESH_TOKEN_NAME) enabled = Attribute("Enabled configuration?") local_mode = Bool( title=_("Enable JWT direct authentication?"), description=_("Enable direct login via JWT authentication"), required=False, default=False) algorithm = Choice( title=_("JWT encoding algorithm"), description=_( "HS* protocols are using the secret, while RS* protocols " "are using RSA keys"), required=False, values=('RS256', 'RS512', 'HS256', 'HS512'), default='RS512') secret = TextLine( title=_("JWT secret"), description=_("This secret is required when using HS* encryption"), required=False) private_key = Text( title=_("JWT private key"), description=_("The secret key is required when using RS* algorithm"), required=False) public_key = Text( title=_("JWT public key"), description=_("The public key is required when using RS* algorithm"), required=False) access_expiration = Int( title=_("Access token lifetime"), description=_("JWT access token lifetime, in seconds"), required=False, default=60 * 60) refresh_expiration = Int( title=_("Refresh token lifetime"), description=_("JWT refresh token lifetime, in seconds"), required=False, default=60 * 60 * 24 * 7) proxy_mode = Bool( title=_("Enable JWT proxy authentication?"), description=_("If this option is enabled, tokens management requests " "will be forwarded to another authentication authority"), required=False, default=False) authority = TextLine( title=_("Authentication authority"), description=_("Base URL (protocol and hostname) of the authentication " "authority to which tokens management requests will be " "forwarded"), required=False) get_token_service = HTTPMethodField( title=_("Token getter service"), description=_("REST HTTP service used to get a new token"), required=False, default=('POST', '/api/auth/jwt/token')) proxy_access_token_name = TextLine( title=_("Access token attribute"), description=_("Name of the JSON attribute returned by " "REST API containing access tokens"), required=False, default=ACCESS_TOKEN_NAME) proxy_refresh_token_name = TextLine( title=_("Refresh token attribute"), description=_("Name of the JSON attribute returned by " "REST API containing refresh tokens"), required=False, default=REFRESH_TOKEN_NAME) get_claims_service = HTTPMethodField( title=_("Token claims getter"), description=_("REST HTTP service used to extract claims " "from provided authorization token"), required=False, default=('GET', '/api/auth/jwt/token')) refresh_token_service = HTTPMethodField( title=_("Token refresh service"), description=_("REST HTTP service used to get a new " "access token with a refresh token"), required=False, default=('PATCH', '/api/auth/jwt/token')) verify_token_service = HTTPMethodField( title=_("Token verify service"), description=_("REST HTTP service used to check " "validity of an existing token"), required=False, default=('POST', '/api/auth/jwt/verify')) verify_ssl = Bool( title=_("Verify SSL?"), description=_("If 'no', SSL certificates will not be verified"), required=False, default=True) use_cache = Bool( title=_("Use verified tokens cache?"), description=_( "If selected, this option allows to store credentials in a " "local cache from which they can be reused"), required=False, default=True) selected_cache = Choice( title=_("Selected tokens cache"), description=_("Beaker cache selected to store validated tokens"), required=False, vocabulary=BEAKER_CACHES_VOCABULARY, default='default') @invariant def check_configuration(self): """Check for JWT configuration""" if self.local_mode and self.proxy_mode: raise Invalid(_("You can't enable both local and proxy modes")) if self.local_mode: if not self.algorithm: raise Invalid( _("You must choose an algorithm to enable JWT authentication" )) if self.algorithm.startswith('HS'): # pylint: disable=no-member if not self.secret: raise Invalid( _("You must define JWT secret to use HS256 algorithm")) elif self.algorithm.startswith('RS'): # pylint: disable=no-member if not (self.private_key and self.public_key): raise Invalid( _("You must define a private and a public key to use " "RS256 algorithm")) if self.proxy_mode: if not self.authority: raise Invalid( _("You must define authentication authority to use proxy mode" )) if self.use_cache and not self.selected_cache: raise Invalid( _("You must choose a cache to enable tokens caching"))
class JWTAuthenticationPlugin(metaclass=ClassPropertyType): """JWT authentication plugin""" prefix = 'jwt' title = _("JWT authentication") audience = None leeway = 0 callback = None json_encoder = None @classproperty def http_header(cls): # pylint: disable=no-self-argument,no-self-use """HTTP header setting""" return get_current_registry().settings.get('pyams.jwt.http_header', 'Authorization') @classproperty def auth_type(cls): # pylint: disable=no-self-argument,no-self-use """HTTP authentication type setting""" return get_current_registry().settings.get('pyams.jwt.auth_type', 'Bearer') @property def configuration(self): # pylint: disable=no-self-use """JWT configuration getter""" try: manager = query_utility(ISecurityManager) if manager is not None: return IJWTSecurityConfiguration(manager) except ConnectionStateError: return None return None @property def enabled(self): """Check if JWT authentication is enabled in security manager""" configuration = self.configuration # pylint: disable=no-member try: return configuration.enabled if (configuration is not None) else False except ConnectionStateError: return False @property def expiration(self): """Get default security manager expiration""" configuration = self.configuration # pylint: disable=no-member return configuration.access_expiration if configuration is not None else None def create_token(self, principal, expiration=None, audience=None, **claims): """Create JWT token""" if not self.enabled: return None configuration = self.configuration payload = {} payload.update(claims) payload['sub'] = principal payload['iat'] = iat = datetime.utcnow() expiration = expiration or self.expiration if expiration: if not isinstance(expiration, timedelta): expiration = timedelta(seconds=expiration) payload['exp'] = iat + expiration audience = audience or self.audience if audience: payload['aud'] = audience # pylint: disable=no-member algorithm = configuration.algorithm if configuration is not None else 'RS512' if algorithm.startswith('HS'): # pylint: disable=no-member key = configuration.secret if configuration is not None else None else: # RS256 # pylint: disable=no-member key = configuration.private_key if configuration is not None else None token = jwt.encode(payload, key, algorithm=algorithm, json_encoder=self.json_encoder) if not isinstance(token, str): token = token.decode('ascii') return token def _get_claims(self, request, obj=None): # pylint: disable=too-many-return-statements """Get JWT claims""" if self.http_header == 'Authorization': # pylint: disable=comparison-with-callable try: if request.authorization is None: return {} except (ValueError, AttributeError): # invalid authorization header return {} (auth_type, token) = request.authorization if auth_type != self.auth_type: # pylint: disable=comparison-with-callable return {} else: token = request.headers.get(self.http_header) if not token: return {} try: configuration = self.configuration # pylint: disable=no-member algorithm = configuration.algorithm if configuration is not None else 'RS512' if algorithm.startswith('HS'): # pylint: disable=no-member key = configuration.secret if configuration is not None else None else: # RS256/RS512 # pylint: disable=no-member key = configuration.public_key if configuration is not None else None claims = jwt.decode(token, key, algorithms=[algorithm], leeway=self.leeway, audience=self.audience) if obj and obj != claims.get('obj'): raise InvalidTokenError('Bad token object!') return claims except InvalidTokenError as exc: LOGGER.warning('Invalid JWT token from %s: %s', getattr(request, 'remote_addr', '--'), exc) return {} def get_claims(self, request, obj=None): # pylint: disable=too-many-return-statements """Get JWT claims""" configuration = self.configuration if configuration is None: return {} if configuration.proxy_mode: handler = IJWTProxyHandler(self) if handler is not None: _status_code, claims = handler.get_claims(request, obj) # pylint: disable=assignment-from-no-return return claims elif configuration.local_mode: return self._get_claims(request, obj) return {} def extract_credentials(self, request, **kwargs): # pylint: disable=unused-argument """Extract principal ID from given request""" claims = self.get_claims(request, obj=ACCESS_OBJECT) if claims: return Credentials(self.prefix, claims.get('sub'), login=claims.get('login')) if claims else None return None def authenticate(self, credentials, request): # pylint: disable=unused-argument """Authenticate JWT token""" claims = self.get_claims(request, obj=ACCESS_OBJECT) return claims.get('sub') if claims else None def unauthenticated_userid(self, request): """Get unauthenticated user ID""" claims = self.get_claims(request, obj=ACCESS_OBJECT) return claims.get('sub') if claims else None
class ClaimsSetterSchema(MappingSchema): """Claims setter schema""" claims = SchemaNode(PropertiesMapping(), title=_("Custom claims"), missing=drop)