got the user routes exposed with swagger, added jwt support to swagger and also updated how the scopes are validated

This commit is contained in:
James Tombleson 2024-05-07 18:19:41 -07:00
parent c765227932
commit c539a20cc7
8 changed files with 822 additions and 32 deletions

View File

@ -903,6 +903,226 @@ const docTemplate = `{
}
}
}
},
"/v1/users/login": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Logs into the API and returns a bearer token if successful",
"parameters": [
{
"type": "string",
"name": "password",
"in": "formData"
},
{
"type": "string",
"name": "username",
"in": "formData"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.LoginResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/refreshToken": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Users"
],
"summary": "Generates a new token",
"parameters": [
{
"description": "body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.RefreshTokenRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.LoginResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/register": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Creates a new user",
"parameters": [
{
"type": "string",
"name": "password",
"in": "formData"
},
{
"type": "string",
"name": "username",
"in": "formData"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/scopes/add": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Adds a new scope to a user account",
"parameters": [
{
"description": "body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateScopesRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/scopes/remove": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Adds a new scope to a user account",
"parameters": [
{
"description": "body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateScopesRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
}
},
"definitions": {
@ -1023,6 +1243,34 @@ const docTemplate = `{
}
}
},
"domain.LoginResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"refreshToken": {
"type": "string"
},
"token": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"domain.RefreshTokenRequest": {
"type": "object",
"properties": {
"refreshToken": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"domain.SourceDto": {
"type": "object",
"properties": {
@ -1059,6 +1307,23 @@ const docTemplate = `{
}
}
}
},
"domain.UpdateScopesRequest": {
"type": "object",
"required": [
"scopes"
],
"properties": {
"scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"username": {
"type": "string"
}
}
}
},
"securityDefinitions": {

View File

@ -894,6 +894,226 @@
}
}
}
},
"/v1/users/login": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Logs into the API and returns a bearer token if successful",
"parameters": [
{
"type": "string",
"name": "password",
"in": "formData"
},
{
"type": "string",
"name": "username",
"in": "formData"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.LoginResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/refreshToken": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Users"
],
"summary": "Generates a new token",
"parameters": [
{
"description": "body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.RefreshTokenRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.LoginResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/register": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Creates a new user",
"parameters": [
{
"type": "string",
"name": "password",
"in": "formData"
},
{
"type": "string",
"name": "username",
"in": "formData"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/scopes/add": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Adds a new scope to a user account",
"parameters": [
{
"description": "body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateScopesRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
},
"/v1/users/scopes/remove": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Adds a new scope to a user account",
"parameters": [
{
"description": "body",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateScopesRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.BaseResponse"
}
}
}
}
}
},
"definitions": {
@ -1014,6 +1234,34 @@
}
}
},
"domain.LoginResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"refreshToken": {
"type": "string"
},
"token": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"domain.RefreshTokenRequest": {
"type": "object",
"properties": {
"refreshToken": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"domain.SourceDto": {
"type": "object",
"properties": {
@ -1050,6 +1298,23 @@
}
}
}
},
"domain.UpdateScopesRequest": {
"type": "object",
"required": [
"scopes"
],
"properties": {
"scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"username": {
"type": "string"
}
}
}
},
"securityDefinitions": {

View File

@ -78,6 +78,24 @@ definitions:
$ref: '#/definitions/domain.DiscordWebHookDto'
type: array
type: object
domain.LoginResponse:
properties:
message:
type: string
refreshToken:
type: string
token:
type: string
type:
type: string
type: object
domain.RefreshTokenRequest:
properties:
refreshToken:
type: string
username:
type: string
type: object
domain.SourceDto:
properties:
enabled:
@ -102,6 +120,17 @@ definitions:
$ref: '#/definitions/domain.SourceDto'
type: array
type: object
domain.UpdateScopesRequest:
properties:
scopes:
items:
type: string
type: array
username:
type: string
required:
- scopes
type: object
info:
contact: {}
title: NewsBot collector
@ -670,6 +699,145 @@ paths:
summary: Creates a new youtube source to monitor.
tags:
- Source
/v1/users/login:
post:
parameters:
- in: formData
name: password
type: string
- in: formData
name: username
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.LoginResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.BaseResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
summary: Logs into the API and returns a bearer token if successful
tags:
- Users
/v1/users/refreshToken:
post:
parameters:
- description: body
in: body
name: request
required: true
schema:
$ref: '#/definitions/domain.RefreshTokenRequest'
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.LoginResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.BaseResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
security:
- Bearer: []
summary: Generates a new token
tags:
- Users
/v1/users/register:
post:
parameters:
- in: formData
name: password
type: string
- in: formData
name: username
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.BaseResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.BaseResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
summary: Creates a new user
tags:
- Users
/v1/users/scopes/add:
post:
consumes:
- application/json
parameters:
- description: body
in: body
name: request
required: true
schema:
$ref: '#/definitions/domain.UpdateScopesRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.BaseResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.BaseResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
summary: Adds a new scope to a user account
tags:
- Users
/v1/users/scopes/remove:
post:
consumes:
- application/json
parameters:
- description: body
in: body
name: request
required: true
schema:
$ref: '#/definitions/domain.UpdateScopesRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.BaseResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.BaseResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.BaseResponse'
summary: Adds a new scope to a user account
tags:
- Users
securityDefinitions:
Bearer:
description: Type "Bearer" followed by a space and JWT token.

View File

@ -1,6 +1,8 @@
package domain
const (
ScopeAll = "newsbot:all"
ScopeRead = "newsbot:read"
ScopeAll = "newsbot:all"
ScopeArticleRead = "newsbot:article:read"
ScopeSourceCreate = "newsbot:source:create"
ScopeDiscordWebHookCreate = "newsbot:discordwebhook:create"
)

View File

@ -15,12 +15,15 @@ const (
ErrUsernameAlreadyExists = "the requested username already exists"
)
// Register
// @Summary Creates a new user
// @Tags Users
// @Router /v1/users/register [post]
// @Param request formData domain.LoginFormRequest true "form"
// @Accepts x-www-form-urlencoded
// @Produce json
// @Tags Users
// @Success 200 {object} domain.BaseResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
func (h *Handler) AuthRegister(c echo.Context) error {
username := c.FormValue("username")
password := c.FormValue("password")
@ -44,7 +47,7 @@ func (h *Handler) AuthRegister(c echo.Context) error {
return h.WriteError(c, err, http.StatusInternalServerError)
}
_, err = h.repo.Users.Create(c.Request().Context(), username, password, domain.ScopeRead)
_, err = h.repo.Users.Create(c.Request().Context(), username, password, domain.ScopeArticleRead)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
@ -54,17 +57,26 @@ func (h *Handler) AuthRegister(c echo.Context) error {
})
}
// @Summary Logs into the API and returns a bearer token if successful
// @Router /v1/users/login [post]
// @Param request formData domain.LoginFormRequest true "form"
// @Accepts x-www-form-urlencoded
// @Produce json
// @Tags Users
// @Success 200 {object} domain.LoginResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
func (h *Handler) AuthLogin(c echo.Context) error {
username := c.FormValue("username")
password := c.FormValue("password")
// Check to see if they are trying to login with the admin token
if username == "" {
return h.validateAdminToken(c, password)
return h.createAdminToken(c, password)
}
// check if the user exists
err := h.repo.Users.DoesUserExist(c.Request().Context(), username)
user, err := h.repo.Users.GetUser(c.Request().Context(), username)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
@ -78,7 +90,7 @@ func (h *Handler) AuthLogin(c echo.Context) error {
// TODO think about moving this down some?
expiresAt := time.Now().Add(time.Hour * 48)
jwt, err := h.generateJwtWithExp(username, h.config.ServerAddress, expiresAt)
jwt, err := h.generateJwtWithExp(username, user.Scopes, h.config.ServerAddress, expiresAt)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
@ -98,7 +110,7 @@ func (h *Handler) AuthLogin(c echo.Context) error {
})
}
func (h *Handler) validateAdminToken(c echo.Context, password string) error {
func (h *Handler) createAdminToken(c echo.Context, password string) error {
// if the admin token is blank, then the admin wanted this disabled.
// this will fail right away and not progress.
if h.config.AdminSecret == "" {
@ -109,15 +121,30 @@ func (h *Handler) validateAdminToken(c echo.Context, password string) error {
return h.UnauthorizedResponse(c, ErrUserNotFound)
}
token, err := h.generateJwt("admin", h.config.ServerAddress)
token, err := h.generateJwt("admin", domain.ScopeAll, h.config.ServerAddress)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
return c.JSON(http.StatusOK, token)
return c.JSON(http.StatusOK, domain.LoginResponse{
BaseResponse: domain.BaseResponse{
Message: "OK",
},
Token: token,
Type: "Bearer",
})
}
// This will take collect some information about the requested refresh, validate and then return a new jwt token if approved.
// Register
// @Summary Generates a new token
// @Router /v1/users/refreshToken [post]
// @Param request body domain.RefreshTokenRequest true "body"
// @Tags Users
// @Success 200 {object} domain.LoginResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
// @Security Bearer
func (h *Handler) RefreshJwtToken(c echo.Context) error {
// Check the context for the refresh token
var request domain.RefreshTokenRequest
@ -131,7 +158,12 @@ func (h *Handler) RefreshJwtToken(c echo.Context) error {
return h.InternalServerErrorResponse(c, err.Error())
}
jwt, err := h.generateJwtWithExp(request.Username, h.config.ServerAddress, time.Now().Add(time.Hour*48))
user, err := h.repo.Users.GetUser(c.Request().Context(), request.Username)
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
jwt, err := h.generateJwtWithExp(request.Username, user.Scopes, h.config.ServerAddress, time.Now().Add(time.Hour*48))
if err != nil {
return h.InternalServerErrorResponse(c, err.Error())
}
@ -151,8 +183,17 @@ func (h *Handler) RefreshJwtToken(c echo.Context) error {
})
}
// @Summary Adds a new scope to a user account
// @Router /v1/users/scopes/add [post]
// @Param request body domain.UpdateScopesRequest true "body"
// @Tags Users
// @Accept json
// @Produce json
// @Success 200 {object} domain.BaseResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
func (h *Handler) AddScopes(c echo.Context) error {
token, err := h.getJwtToken(c)
token, err := h.getJwtTokenFromContext(c)
if err != nil {
return h.UnauthorizedResponse(c, err.Error())
}
@ -178,8 +219,17 @@ func (h *Handler) AddScopes(c echo.Context) error {
})
}
// @Summary Adds a new scope to a user account
// @Router /v1/users/scopes/remove [post]
// @Param request body domain.UpdateScopesRequest true "body"
// @Tags Users
// @Accept json
// @Produce json
// @Success 200 {object} domain.BaseResponse
// @Failure 400 {object} domain.BaseResponse
// @Failure 500 {object} domain.BaseResponse
func (h *Handler) RemoveScopes(c echo.Context) error {
token, err := h.getJwtToken(c)
token, err := h.getJwtTokenFromContext(c)
if err != nil {
return h.WriteError(c, err, http.StatusUnauthorized)
}

View File

@ -100,6 +100,14 @@ func NewServer(ctx context.Context, configs services.Configs, conn *sql.DB) *Han
sources.POST("/:ID/disable", s.disableSource)
sources.POST("/:ID/enable", s.enableSource)
users := v1.Group("/users")
users.POST("/login", s.AuthLogin)
users.POST("/register", s.AuthRegister)
users.Use(echojwt.WithConfig(jwtConfig))
users.POST("/scopes/add", s.AddScopes)
users.POST("/scopes/remove", s.RemoveScopes)
users.POST("/refreshToken", s.RefreshJwtToken)
s.Router = router
return s
}
@ -136,3 +144,29 @@ func (s *Handler) UnauthorizedResponse(c echo.Context, msg string) error {
Message: msg,
})
}
// If the token is not valid then an json error will be returned.
// If the token has the wrong scope, a json error will be returned.
// If the token passes all the checks, it is valid and is returned back to the caller.
func (s *Handler) ValidateJwtToken(c echo.Context, requiredScope string) JwtToken {
token, err := s.getJwtTokenFromContext(c)
if err != nil {
s.WriteMessage(c, ErrJwtMissing, http.StatusUnauthorized)
}
err = token.hasScope(requiredScope)
if err != nil {
s.WriteMessage(c, ErrJwtScopeMissing, http.StatusUnauthorized)
}
if token.Iss != s.config.ServerAddress {
s.WriteMessage(c, ErrJwtInvalidIssuer, http.StatusUnauthorized)
}
err = token.hasExpired()
if err != nil {
s.WriteMessage(c, ErrJwtExpired, http.StatusUnauthorized)
}
return token
}

View File

@ -15,6 +15,7 @@ const (
ErrJwtClaimsMissing = "claims missing on token"
ErrJwtExpired = "auth token has expired"
ErrJwtScopeMissing = "required scope is missing"
ErrJwtInvalidIssuer = "incorrect server issued the token"
)
type JwtToken struct {
@ -32,6 +33,13 @@ func (j JwtToken) IsValid(scope string) error {
return err
}
// Check to see if they have the scope to do anything
// if they do, let them pass
err = j.hasScope(domain.ScopeAll)
if err == nil {
return nil
}
err = j.hasScope(scope)
if err != nil {
return err
@ -53,25 +61,27 @@ func (j JwtToken) hasExpired() error {
return nil
}
// This will check the users token to make sure they have the correct scope to access the handler.
// It will evaluate if you have the admin scope or the required scope for the handler.
func (j JwtToken) hasScope(scope string) error {
// they have the scope to access everything, so let them pass.
if strings.Contains(domain.ScopeAll, scope) {
userScopes := strings.Join(j.Scopes, "")
if strings.Contains(domain.ScopeAll, userScopes) {
return nil
}
for _, s := range j.Scopes {
if strings.Contains(s, scope) {
return nil
}
if strings.Contains(userScopes, scope) {
return nil
}
return errors.New(ErrJwtScopeMissing)
}
func (h *Handler) generateJwt(username, issuer string) (string, error) {
return h.generateJwtWithExp(username, issuer, time.Now().Add(10*time.Minute))
func (h *Handler) generateJwt(username, scopes, issuer string) (string, error) {
return h.generateJwtWithExp(username, scopes, issuer, time.Now().Add(10*time.Minute))
}
func (h *Handler) generateJwtWithExp(username, issuer string, expiresAt time.Time) (string, error) {
func (h *Handler) generateJwtWithExp(username, userScopes, issuer string, expiresAt time.Time) (string, error) {
secret := []byte(h.config.JwtSecret)
// Anyone who wants to decrypt the key needs to use the same method
@ -83,13 +93,8 @@ func (h *Handler) generateJwtWithExp(username, issuer string, expiresAt time.Tim
claims["iss"] = issuer
var scopes []string
if username == "admin" {
scopes = append(scopes, domain.ScopeAll)
claims["scopes"] = scopes
} else {
scopes = append(scopes, domain.ScopeRead)
claims["scopes"] = scopes
}
scopes = append(scopes, domain.ScopeAll)
claims["scopes"] = scopes
tokenString, err := token.SignedString(secret)
if err != nil {
@ -99,7 +104,7 @@ func (h *Handler) generateJwtWithExp(username, issuer string, expiresAt time.Tim
return tokenString, nil
}
func (h *Handler) getJwtToken(c echo.Context) (JwtToken, error) {
func (h *Handler) getJwtTokenFromContext(c echo.Context) (JwtToken, error) {
// Make sure that the request came with a jwtToken
token, ok := c.Get("user").(*jwt.Token)
if !ok {

View File

@ -67,6 +67,7 @@ func GetEnvConfig() Configs {
return Configs{
ServerAddress: os.Getenv(ServerAddress),
JwtSecret: os.Getenv("JwtSecret"),
AdminSecret: os.Getenv("AdminSecret"),
RedditEnabled: processBoolConfig(os.Getenv(FEATURE_ENABLE_REDDIT_BACKEND)),
RedditPullTop: processBoolConfig(os.Getenv(REDDIT_PULL_TOP)),