Protegendo endpoints individuais de API REST
Eu estava tentando encontrar uma solução para conceder aos clientes acesso anônimo a determinados endpoints de API e também proteger outros endpoints na minha API REST. No entanto, ao definir um Web App, você só pode proteger o aplicativo inteiro, e não partes específicas.
Procurei respostas na comunidade, mas não encontrei nenhuma solução exata, exceto uma recomendação para criar dois Web Apps separados, um protegido e outro não. No entanto, na minha opinião, essa estratégia requer muito trabalho e cria um overhead de manutenção desnecessário. Prefiro desenvolver minhas APIs começando pela especificação e decidir nela quais endpoints devem permitir o acesso anônimo ou não.
Neste artigo, forneço dois exemplos: um para a Autenticação Básica e outro para o JWT, que é usado no contexto do OAuth 2.0. Se você notar alguma falha nestes exemplos, me avise e farei as correções necessárias.
Pré-requisitos
Primeiro, defina um Web App para sua API REST. Configure-o para o acesso não autenticado e especifique os privilégios necessários para o aplicativo. Especifique apenas as funções e os recursos necessários para o sucesso do uso da API.
Crie uma classe, por exemplo, REST.Utils, onde você implementará os classmethods helper que verificam as credenciais.
Class REST.Utils
{
}
Autenticação Básica
Se você quiser um endpoint seguro usando a Autenticação Básica, use o seguinte método para verificar se o nome de usuário e a senha informados no cabeçalho da Autorização HTTP têm os privilégios corretos para acessar o endpoint de API restrito.
/// Confira se os usuários têm as permissões necessárias.
/// - auth: o cabeçalho da Autorização.
/// - resource: o recurso para verificar as permissões.
/// - permissions: as permissões verificadas.
///
/// Exemplo:
/// > Do ##class(REST.Utils).CheckBasicCredentials(%request.GetCgiEnv("HTTP_AUTHORIZATION", ""), "RESOURCE", "U")
///
/// Retorne: %Status. O status da verificação.
ClassMethod CheckBasicCredentials(auth As %String, resource As %String, permissions As %String) As %Status
{
/// Analise a sanidade da entrada
if (auth = "") {
Return $$$ERROR($$$GeneralError, "No Authorization header provided")
}
/// Confira se o cabeçalho da autorização começa com Basic
if ($FIND(auth, "Basic") > 0) {
/// Retire a parte "Basic" do cabeçalho de Autorização e remova os espaços à esquerda e à direita.
set auth = $ZSTRIP($PIECE(auth, "Basic", 2), "<>", "W")
}
Set tStatus = $$$OK
/// Decodifique o nome de usuário e a senha codificados em base64
Set auth = $SYSTEM.Encryption.Base64Decode(auth)
Set username = $PIECE(auth, ":", 1)
Set password = $PIECE(auth, ":", 2)
/// Tente fazer login como o usuário informado no cabeçalho da Autorização
Set tStatus = $SYSTEM.Security.Login(username, password)
if $$$ISERR(tStatus) {
Return tStatus
}
/// Confira se o usuário tem as permissões necessárias
Set tStatus = $SYSTEM.Security.Check(resource, permissions)
/// Retorne o status. Se o usuário tiver as permissões necessárias, o status será $$$OK
Return tStatus
}
No endpoint que você quer proteger, chame o método CheckBasicCredentials e confira o valor retornado. Um valor de retorno 0 indica a falha na verificação. Nesses casos, retornamos HTTP 401 de volta ao cliente.
O exemplo abaixo verifica se o usuário tem o recurso SYSTEM_API definido com privilégios USE. Se não tiver, retorne HTTP 401 ao cliente. Lembre-se de que o usuário da API precisa ter o privilégio %Service_Login:USE para usar o método Security.Login.
Exemplo
Set authHeader = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
Set tStatus = ##class(REST.Utils).CheckBasicCredentials(authHeader, "SYSTEM_API", "U")
if ($$$ISERR(tStatus)) {
Set %response.Status = 401
Return
}
... resto do código
JWT
Em vez de usar a Autenticação Básica para proteger um endpoint, prefiro usar os Tokens de Acesso JWT do OAuth 2.0, já que são mais seguros e oferecem uma maneira mais flexível de definir privilégios por escopos. O seguinte método verifica se o token de acesso JWT fornecido no cabeçalho da Autorização HTTP tem os privilégios corretos para acessar o endpoint de API restrito.
/// Verifique se o JWT fornecido é válido.
/// - auth: o cabeçalho da Autorização.
/// - scopes: os escopos que esse token JWT deve ter.
/// - oauthClient: o cliente OAuth que é usado para validar o token JWT. (opcional)
/// - jwks: o JWKS usado para a validação da assinatura do token (opcional)
///
/// Exemplo:
/// > Set token = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
/// > Do ##class(REST.Utils).CheckJWTCredentials(token, "scope1,scope2")
///
/// Retorne: %Status. O status da verificação.
ClassMethod CheckJWTCredentials(token As %String, scopes As %String, oauthClient As %String = "", jwks As %String = "") As %Status
{
Set tStatus = $$$OK
/// Analise a sanidade da entrada
if (token = "") {
Return $$$ERROR($$$GeneralError, "No token provided")
}
/// Confira se o cabeçalho da autorização começa com Bearer. Se sim, faça a limpeza do token.
if ($FIND(token, "Bearer") > 0) {
/// Retire a parte "Bearer" do cabeçalho de Autorização e remova os espaços à esquerda e à direita.
set token = $ZSTRIP($PIECE(token, "Bearer", 2), "<>", "W")
}
/// Crie uma lista da string de escopos
Set scopes = $LISTFROMSTRING(scopes, ",")
Set scopeList = ##class(%ListOfDataTypes).%New()
Do scopeList.InsertList(scopes)
/// Remova os espaços em branco de cada escopo
For i=1:1:scopeList.Count() {
Do scopeList.SetAt($ZSTRIP(scopeList.GetAt(i), "<>", "W"), i)
}
/// Decodifique o token
Try {
Do ..JWTToObject(token, .payload, .header)
} Catch ex {
Return $$$ERROR($$$GeneralError, "Not a valid JWT token. Exception code: " _ ex.Code _ ". Status: " _ ex.AsStatus())
}
/// Obtenha o epoch time atual
Set now = $ZDATETIME($h,-2)
/// Confira se o token expirou
if (payload.exp < now) {
Return $$$ERROR($$$GeneralError, "Token has expired")
}
Set scopesFound = 0
/// Confira se o token tem os escopos necessários
for i=1:1:scopeList.Count() {
Set scope = scopeList.GetAt(i)
Set scopeIter = payload.scope.%GetIterator()
While scopeIter.%GetNext(.key, .jwtScope) {
if (scope = jwtScope) {
Set scopesFound = scopesFound + 1
}
}
}
if (scopesFound < scopeList.Count()) {
Return $$$ERROR($$$GeneralError, "Token does not have the required scopes")
}
/// Se o token é válido em todo o escopo e não expirou, confira se a assinatura é valida
if (oauthClient '= "") {
/// Se especificamos um cliente OAuth, use isso para validar a assinatura do token
Set result = ##class(%SYS.OAuth2.Validation).ValidateJWT(oauthClient, token, , , , , .tStatus,)
if ($$$ISERR(tStatus) || result '= 1) {
Return $$$ERROR($$$GeneralError, "Token failed signature validation")
}
} elseif (jwks '= "") {
/// Se especificamos um JWKS, use isso para validar a assinatura do token
Set tStatus = ##class(%OAuth2.JWT).JWTToObject(token,,jwks,,,)
if ($$$ISERR(tStatus)) {
Return $$$ERROR($$$GeneralError, "Token failed signature validation. Reason: " _ $SYSTEM.Status.GetErrorText(tStatus))
}
}
Return tStatus
}
/// Decodifique um token JWT.
/// - token: o token JWT a decodificar.
/// - payload: o payload do token JWT. (Saída)
/// - header: o cabeçalho do token JWT. (Saída)
///
/// Exemplo:
/// > Set token = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
/// > Do ##class(REST.Utils).JWTToObject(token, .payload, .header)
///
/// Retorne: %Status. O status da verificação.
ClassMethod JWTToObject(token As %String, Output payload As %DynamicObject, Output header As %DynamicObject) As %Status
{
Set $LISTBUILD(header, payload, sign) = $LISTFROMSTRING(token, ".")
/// Decodifique e processe o Cabeçalho
Set header = $SYSTEM.Encryption.Base64Decode(header)
Set header = {}.%FromJSON(header)
/// Decodifique e processe o Payload
Set payload = $SYSTEM.Encryption.Base64Decode(payload)
Set payload = {}.%FromJSON(payload)
Return $$$OK
}
Novamente, no endpoint que você quer proteger, chame o método CheckJWTCredentials e confira o valor retornado. Um valor de retorno 0 indica a falha na verificação. Nesses casos, retornamos HTTP 401 de volta ao cliente.
O exemplo abaixo verifica se o token tem os escopos scope1 e scope2 definidos. Se não tiver os escopos necessários, houver expirado ou falhar na validação da assinatura, ele retornará um código de status HTTP 401 ao cliente.
Exemplo
Set authHeader = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
Set tStatus = ##class(REST.Utils).CheckJWTCredentials(authHeader, "scope1,scope2")
if ($$$ISERR(tStatus)) {
Set %response.Status = 401
Return
}
... resto do código
Conclusão
Aqui está o código completo para a classe REST.Utils. Se você tiver quaisquer sugestões sobre como melhorar o código, me informe. Atualizarei o artigo devidamente.
Uma melhoria óbvia seria verificar a assinatura JWT para garantir que é válida. Para fazer isso, você precisa ter a chave pública do emissor.
Class REST.Utils
{
/// Confira se os usuários têm as permissões necessárias.
/// - auth: o conteúdo do cabeçalho da Autorização.
/// - resource: o recurso para verificar as permissões.
/// - permissions: as permissões verificadas.
///
/// Exemplo:
/// > Set authHeader = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
/// > Do ##class(REST.Utils).CheckBasicCredentials(authHeader, "RESOURCE", "U"))
///
/// Retorne: %Status. O status da verificação.
ClassMethod CheckBasicCredentials(authHeader As %String, resource As %String, permissions As %String) As %Status
{
Set auth = authHeader
/// Analise a sanidade da entrada
if (auth = "") {
Return $$$ERROR($$$GeneralError, "No Authorization header provided")
}
/// Confira se o cabeçalho da autorização começa com Basic
if ($FIND(auth, "Basic") > 0) {
// Retire a parte "Basic" do cabeçalho de Autorização e remova os espaços à esquerda e à direita.
set auth = $ZSTRIP($PIECE(auth, "Basic", 2), "<>", "W")
}
Set tStatus = $$$OK
Try {
/// Decodifique o nome de usuário e a senha codificados em base64
Set auth = $SYSTEM.Encryption.Base64Decode(auth)
Set username = $PIECE(auth,":",1)
Set password = $PIECE(auth,":",2)
} Catch {
Return $$$ERROR($$$GeneralError, "Not a valid Basic Authorization header")
}
/// Tente fazer login como o usuário informado no cabeçalho da Autorização
Set tStatus = $SYSTEM.Security.Login(username,password)
if $$$ISERR(tStatus) {
Return tStatus
}
/// Confira se o usuário tem as permissões necessárias
Set tStatus = $SYSTEM.Security.Check(resource, permissions)
/// Retorne o status. Se o usuário tiver as permissões necessárias, o status será $$$OK
Return tStatus
}
/// Verifique se o JWT fornecido é válido.
/// - auth: o cabeçalho da Autorização.
/// - scopes: os escopos que esse token JWT deve ter.
/// - oauthClient: o cliente OAuth que é usado para validar o token JWT. (opcional)
/// - jwks: o JWKS usado para a validação da assinatura do token (opcional)
///
/// Exemplo:
/// > Set token = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
/// > Do ##class(REST.Utils).CheckJWTCredentials(token, "scope1,scope2")
///
/// Retorne: %Status. O status da verificação.
ClassMethod CheckJWTCredentials(token As %String, scopes As %String, oauthClient As %String = "", jwks As %String = "") As %Status
{
Set tStatus = $$$OK
/// Analise a sanidade da entrada
if (token = "") {
Return $$$ERROR($$$GeneralError, "No token provided")
}
/// Confira se o cabeçalho da autorização começa com Bearer. Se sim, faça a limpeza do token.
if ($FIND(token, "Bearer") > 0) {
/// Retire a parte "Bearer" do cabeçalho de Autorização e remova os espaços à esquerda e à direita.
set token = $ZSTRIP($PIECE(token, "Bearer", 2), "<>", "W")
}
/// Crie uma lista da string de escopos
Set scopes = $LISTFROMSTRING(scopes, ",")
Set scopeList = ##class(%ListOfDataTypes).%New()
Do scopeList.InsertList(scopes)
/// Remova os espaços em branco de cada escopo
For i=1:1:scopeList.Count() {
Do scopeList.SetAt($ZSTRIP(scopeList.GetAt(i), "<>", "W"), i)
}
/// Decodifique o token
Try {
Do ..JWTToObject(token, .payload, .header)
} Catch ex {
Return $$$ERROR($$$GeneralError, "Not a valid JWT token. Exception code: " _ ex.Code _ ". Status: " _ ex.AsStatus())
}
/// Obtenha o epoch time atual
Set now = $ZDATETIME($h,-2)
/// Confira se o token expirou
if (payload.exp < now) {
Return $$$ERROR($$$GeneralError, "Token has expired")
}
Set scopesFound = 0
/// Confira se o token tem os escopos necessários
for i=1:1:scopeList.Count() {
Set scope = scopeList.GetAt(i)
Set scopeIter = payload.scope.%GetIterator()
While scopeIter.%GetNext(.key, .jwtScope) {
if (scope = jwtScope) {
Set scopesFound = scopesFound + 1
}
}
}
if (scopesFound < scopeList.Count()) {
Return $$$ERROR($$$GeneralError, "Token does not have the required scopes")
}
/// Se o token é válido em todo o escopo e não expirou, confira se a assinatura é valida
if (oauthClient '= "") {
/// Se especificamos um cliente OAuth, use isso para validar a assinatura do token
Set result = ##class(%SYS.OAuth2.Validation).ValidateJWT(oauthClient, token, , , , , .tStatus,)
if ($$$ISERR(tStatus) || result '= 1) {
Return $$$ERROR($$$GeneralError, "Token failed signature validation")
}
} elseif (jwks '= "") {
/// Se especificamos um JWKS, use isso para validar a assinatura do token
Set tStatus = ##class(%OAuth2.JWT).JWTToObject(token,,jwks,,,)
if ($$$ISERR(tStatus)) {
Return $$$ERROR($$$GeneralError, "Token failed signature validation. Reason: " _ $SYSTEM.Status.GetErrorText(tStatus))
}
}
Return tStatus
}
/// Decodifique um token JWT.
/// - token: o token JWT a decodificar.
/// - payload: o payload do token JWT. (Saída)
/// - header: o cabeçalho do token JWT. (Saída)
///
/// Exemplo:
/// > Set token = %request.GetCgiEnv("HTTP_AUTHORIZATION", "")
/// > Do ##class(REST.Utils).JWTToObject(token, .payload, .header)
///
/// Retorne: %Status. O status da verificação.
ClassMethod JWTToObject(token As %String, Output payload As %DynamicObject, Output header As %DynamicObject) As %Status
{
Set $LISTBUILD(header, payload, sign) = $LISTFROMSTRING(token, ".")
/// Decodifique e processe o Cabeçalho
Set header = $SYSTEM.Encryption.Base64Decode(header)
Set header = {}.%FromJSON(header)
/// Decodifique e processe o Payload
Set payload = $SYSTEM.Encryption.Base64Decode(payload)
Set payload = {}.%FromJSON(payload)
Return $$$OK
}
}