API Tokens, Documentation

- API Tokens
- More documentation
- Update API
This commit is contained in:
Matej Kramny 2016-04-09 20:09:57 +01:00
parent 5d332266ac
commit d6370c875e
11 changed files with 477 additions and 72 deletions

View File

@ -0,0 +1,8 @@
create table `user__token` (
`id` varchar(32) not null primary key,
`created` datetime not null default NOW(),
`expired` tinyint(1) not null default 0,
`user_id` int(11) not null,
foreign key (`user_id`) references user(`id`) on delete cascade
) ENGINE=InnoDB CHARSET=utf8;

18
models/APIToken.go Normal file
View File

@ -0,0 +1,18 @@
package models
import (
"github.com/ansible-semaphore/semaphore/database"
"time"
)
type APIToken struct {
ID string `db:"id" json:"id"`
Created time.Time `db:"created" json:"created"`
Expired bool `db:"expired" json:"expired"`
UserID int `db:"user_id" json:"user_id"`
}
func init() {
database.Mysql.AddTableWithName(APIToken{}, "user__token").SetKeys(false, "id")
}

View File

@ -0,0 +1,3 @@
br
h4.text-center.text-muted Scheduled tasks are WIP

View File

@ -7,6 +7,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"time" "time"
"github.com/ansible-semaphore/semaphore/database" "github.com/ansible-semaphore/semaphore/database"
@ -16,56 +17,67 @@ import (
"gopkg.in/redis.v3" "gopkg.in/redis.v3"
) )
func resetSessionExpiry(sessionID string) { func resetSessionExpiry(sessionID string, ttl time.Duration) {
if err := database.Redis.Expire(sessionID, 7*24*time.Hour).Err(); err != nil { var cmd *redis.BoolCmd
if ttl == 0 {
cmd = database.Redis.Persist(sessionID)
} else {
cmd = database.Redis.Expire(sessionID, ttl)
}
if err := cmd.Err(); err != nil {
fmt.Println("Cannot reset session expiry:", err) fmt.Println("Cannot reset session expiry:", err)
} }
} }
func authentication(c *gin.Context) { func authentication(c *gin.Context) {
cookie, err := c.Request.Cookie("semaphore") var redisKey string
if err != nil { ttl := 7 * 24 * time.Hour
// create cookie
new_cookie := make([]byte, 32) if authHeader := strings.ToLower(c.Request.Header.Get("authorization")); len(authHeader) > 0 {
if _, err := io.ReadFull(rand.Reader, new_cookie); err != nil { redisKey = "token-session:" + strings.Replace(authHeader, "bearer ", "", 1)
panic(err) ttl = 0
} else {
cookie, err := c.Request.Cookie("semaphore")
if err != nil {
// create cookie
new_cookie := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, new_cookie); err != nil {
panic(err)
}
cookie_value := url.QueryEscape(base64.URLEncoding.EncodeToString(new_cookie))
cookie = &http.Cookie{Name: "semaphore", Value: cookie_value, Path: "/", HttpOnly: true}
http.SetCookie(c.Writer, cookie)
} }
cookie_value := url.QueryEscape(base64.URLEncoding.EncodeToString(new_cookie)) redisKey = "session:" + cookie.Value
cookie = &http.Cookie{Name: "semaphore", Value: cookie_value, Path: "/", HttpOnly: true}
http.SetCookie(c.Writer, cookie)
} }
redis_key := "session:" + cookie.Value s, err := database.Redis.Get(redisKey).Result()
s, err := database.Redis.Get(redis_key).Result()
if err == redis.Nil { if err == redis.Nil {
// create a session // create a session
temp_session := models.Session{} temp_session := models.Session{}
s = string(temp_session.Encode()) s = string(temp_session.Encode())
if err := database.Redis.Set(redis_key, s, 0).Err(); err != nil { if err := database.Redis.Set(redisKey, s, 0).Err(); err != nil {
panic(err) panic(err)
} }
} else if err != nil { } else if err != nil {
fmt.Println("Cannot get cookie from redis:", err) fmt.Println("Cannot get session from redis:", err)
c.AbortWithStatus(500) c.AbortWithStatus(500)
return return
} }
// reset session expiry sess, err := models.DecodeSession(redisKey, s)
go resetSessionExpiry(redis_key)
sess, err := models.DecodeSession(cookie.Value, s)
if err != nil { if err != nil {
fmt.Println("Cannot decode session:", err) fmt.Println("Cannot decode session:", err)
util.AuthFailed(c) util.AuthFailed(c)
return return
} }
sess.ID = cookie.Value
c.Set("session", sess)
if sess.UserID != nil { if sess.UserID != nil {
user, err := models.FetchUser(*sess.UserID) user, err := models.FetchUser(*sess.UserID)
if err != nil { if err != nil {
@ -77,6 +89,10 @@ func authentication(c *gin.Context) {
c.Set("user", user) c.Set("user", user)
} }
// reset session expiry
go resetSessionExpiry(redisKey, ttl)
c.Set("session", sess)
c.Next() c.Next()
} }

View File

@ -1,4 +1,4 @@
package auth package routes
import ( import (
"database/sql" "database/sql"
@ -14,7 +14,7 @@ import (
sq "github.com/masterminds/squirrel" sq "github.com/masterminds/squirrel"
) )
func Login(c *gin.Context) { func login(c *gin.Context) {
var login struct { var login struct {
Auth string `json:"auth" binding:"required"` Auth string `json:"auth" binding:"required"`
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
@ -56,7 +56,7 @@ func Login(c *gin.Context) {
session := c.MustGet("session").(models.Session) session := c.MustGet("session").(models.Session)
session.UserID = &user.ID session.UserID = &user.ID
status := database.Redis.Set("session:"+session.ID, string(session.Encode()), 7*24*time.Hour) status := database.Redis.Set(session.ID, string(session.Encode()), 7*24*time.Hour)
if err := status.Err(); err != nil { if err := status.Err(); err != nil {
panic(err) panic(err)
} }
@ -64,9 +64,9 @@ func Login(c *gin.Context) {
c.AbortWithStatus(204) c.AbortWithStatus(204)
} }
func Logout(c *gin.Context) { func logout(c *gin.Context) {
session := c.MustGet("session").(models.Session) session := c.MustGet("session").(models.Session)
if err := database.Redis.Del("session:" + session.ID).Err(); err != nil { if err := database.Redis.Del(session.ID).Err(); err != nil {
panic(err) panic(err)
} }

View File

@ -1,6 +1,8 @@
package projects package projects
import ( import (
"database/sql"
"github.com/ansible-semaphore/semaphore/database" "github.com/ansible-semaphore/semaphore/database"
"github.com/ansible-semaphore/semaphore/models" "github.com/ansible-semaphore/semaphore/models"
"github.com/ansible-semaphore/semaphore/util" "github.com/ansible-semaphore/semaphore/util"
@ -9,6 +11,23 @@ import (
) )
func KeyMiddleware(c *gin.Context) { func KeyMiddleware(c *gin.Context) {
project := c.MustGet("project").(models.Project)
keyID, err := util.GetIntParam("key_id", c)
if err != nil {
return
}
var key models.AccessKey
if err := database.Mysql.SelectOne(&key, "select * from access_key where project_id=? and id=?", project.ID, keyID); err != nil {
if err == sql.ErrNoRows {
c.AbortWithStatus(404)
return
}
panic(err)
}
c.Set("accessKey", key)
c.Next() c.Next()
} }
@ -25,7 +44,6 @@ func GetKeys(c *gin.Context) {
} }
query, args, _ := q.ToSql() query, args, _ := q.ToSql()
if _, err := database.Mysql.Select(&keys, query, args...); err != nil { if _, err := database.Mysql.Select(&keys, query, args...); err != nil {
panic(err) panic(err)
} }
@ -57,17 +75,33 @@ func AddKey(c *gin.Context) {
} }
func UpdateKey(c *gin.Context) { func UpdateKey(c *gin.Context) {
c.AbortWithStatus(501) var key models.AccessKey
} oldKey := c.MustGet("accessKey").(models.AccessKey)
func RemoveKey(c *gin.Context) { if err := c.Bind(&key); err != nil {
project := c.MustGet("project").(models.Project)
keyID, err := util.GetIntParam("key_id", c)
if err != nil {
return return
} }
if _, err := database.Mysql.Exec("delete from access_key where project_id=? and id=?", project.ID, keyID); err != nil { switch key.Type {
case "aws", "gcloud", "do", "ssh":
break
default:
c.AbortWithStatus(400)
return
}
if _, err := database.Mysql.Exec("update access_key set name=?, type=?, `key`=?, secret=?", key.Name, key.Type, key.Key, key.Secret, oldKey.ID); err != nil {
panic(err)
}
c.AbortWithStatus(204)
}
func RemoveKey(c *gin.Context) {
project := c.MustGet("project").(models.Project)
key := c.MustGet("accessKey").(models.AccessKey)
if _, err := database.Mysql.Exec("delete from access_key where project_id=? and id=?", project.ID, key.ID); err != nil {
panic(err) panic(err)
} }

View File

@ -1,6 +1,8 @@
package projects package projects
import ( import (
"database/sql"
"github.com/ansible-semaphore/semaphore/database" "github.com/ansible-semaphore/semaphore/database"
"github.com/ansible-semaphore/semaphore/models" "github.com/ansible-semaphore/semaphore/models"
"github.com/ansible-semaphore/semaphore/util" "github.com/ansible-semaphore/semaphore/util"
@ -9,6 +11,23 @@ import (
) )
func RepositoryMiddleware(c *gin.Context) { func RepositoryMiddleware(c *gin.Context) {
project := c.MustGet("project").(models.Project)
repositoryID, err := util.GetIntParam("repository_id", c)
if err != nil {
return
}
var repository models.Repository
if err := database.Mysql.SelectOne(&repository, "select * from project__repository where project_id=? and id=?", project.ID, repositoryID); err != nil {
if err == sql.ErrNoRows {
c.AbortWithStatus(404)
return
}
panic(err)
}
c.Set("repository", repository)
c.Next() c.Next()
} }
@ -44,15 +63,23 @@ func AddRepository(c *gin.Context) {
} }
func UpdateRepository(c *gin.Context) { func UpdateRepository(c *gin.Context) {
c.AbortWithStatus(501) project := c.MustGet("project").(models.Project)
var repository models.Repository
if err := c.Bind(&repository); err != nil {
return
}
if _, err := database.Mysql.Exec("update project__repository set git_url=?, ssh_key_id=? where id=?", repository.GitUrl, repository.SshKeyID, c.MustGet("repository").(models.Repository).ID); err != nil {
panic(err)
}
c.AbortWithStatus(204)
} }
func RemoveRepository(c *gin.Context) { func RemoveRepository(c *gin.Context) {
project := c.MustGet("project").(models.Project) project := c.MustGet("project").(models.Project)
repositoryID, err := util.GetIntParam("repository_id", c) repository := c.MustGet("repository").(models.Repository)
if err != nil {
return
}
if _, err := database.Mysql.Exec("delete from project__repository where project_id=? and id=?", project.ID, repositoryID); err != nil { if _, err := database.Mysql.Exec("delete from project__repository where project_id=? and id=?", project.ID, repositoryID); err != nil {
panic(err) panic(err)

View File

@ -1,6 +1,8 @@
package projects package projects
import ( import (
"database/sql"
"github.com/ansible-semaphore/semaphore/database" "github.com/ansible-semaphore/semaphore/database"
"github.com/ansible-semaphore/semaphore/models" "github.com/ansible-semaphore/semaphore/models"
"github.com/ansible-semaphore/semaphore/util" "github.com/ansible-semaphore/semaphore/util"
@ -9,6 +11,23 @@ import (
) )
func UserMiddleware(c *gin.Context) { func UserMiddleware(c *gin.Context) {
project := c.MustGet("project").(models.Project)
userID, err := util.GetIntParam("user_id", c)
if err != nil {
return
}
var user models.User
if err := database.Mysql.SelectOne(&user, "select u.* from project__user as pu join user as u on pu.user_id=u.id where pu.user_id=? and pu.project_id=?", userID, project.ID); err != nil {
if err == sql.ErrNoRows {
c.AbortWithStatus(404)
return
}
panic(err)
}
c.Set("projectUser", user)
c.Next() c.Next()
} }
@ -49,12 +68,9 @@ func AddUser(c *gin.Context) {
func RemoveUser(c *gin.Context) { func RemoveUser(c *gin.Context) {
project := c.MustGet("project").(models.Project) project := c.MustGet("project").(models.Project)
userID, err := util.GetIntParam("user_id", c) user := c.MustGet("projectUser").(models.User)
if err != nil {
return
}
if _, err := database.Mysql.Exec("delete from project__user where user_id=? and project_id=?", userID, project.ID); err != nil { if _, err := database.Mysql.Exec("delete from project__user where user_id=? and project_id=?", user.ID, project.ID); err != nil {
panic(err) panic(err)
} }
@ -62,9 +78,18 @@ func RemoveUser(c *gin.Context) {
} }
func MakeUserAdmin(c *gin.Context) { func MakeUserAdmin(c *gin.Context) {
project := c.MustGet("project").(models.Project)
user := c.MustGet("projectUser").(models.User)
admin := 1
if c.Request.Method == "DELETE" { if c.Request.Method == "DELETE" {
// strip admin // strip admin
admin = 0
} }
c.AbortWithStatus(501) if _, err := database.Mysql.Exec("update project__user set admin=? where user_id=? and project_id=?", admin, user.ID, project.ID); err != nil {
panic(err)
}
c.AbortWithStatus(204)
} }

View File

@ -3,7 +3,6 @@ package routes
import ( import (
"strings" "strings"
"github.com/ansible-semaphore/semaphore/routes/auth"
"github.com/ansible-semaphore/semaphore/routes/projects" "github.com/ansible-semaphore/semaphore/routes/projects"
"github.com/ansible-semaphore/semaphore/routes/sockets" "github.com/ansible-semaphore/semaphore/routes/sockets"
"github.com/ansible-semaphore/semaphore/routes/tasks" "github.com/ansible-semaphore/semaphore/routes/tasks"
@ -25,16 +24,22 @@ func Route(r *gin.Engine) {
api.Use(authentication) api.Use(authentication)
func(api *gin.RouterGroup) { func(api *gin.RouterGroup) {
api.POST("/login", auth.Login) api.POST("/login", login)
api.POST("/logout", auth.Logout) api.POST("/logout", logout)
}(api.Group("/auth")) }(api.Group("/auth"))
api.Use(MustAuthenticate) api.Use(MustAuthenticate)
api.GET("/ws", sockets.Handler) api.GET("/ws", sockets.Handler)
api.GET("/user", getUser) func(api *gin.RouterGroup) {
// api.PUT("/user", misc.UpdateUser) api.GET("", getUser)
// api.PUT("/user", misc.UpdateUser)
api.GET("/tokens", getAPITokens)
api.POST("/tokens", createAPIToken)
api.DELETE("/tokens/:token_id", expireAPIToken)
}(api.Group("/user"))
api.GET("/projects", projects.GetProjects) api.GET("/projects", projects.GetProjects)
api.POST("/projects", projects.AddProject) api.POST("/projects", projects.AddProject)
@ -129,7 +134,3 @@ func servePublic(c *gin.Context) {
c.Writer.Header().Set("content-type", contentType) c.Writer.Header().Set("content-type", contentType)
c.String(200, string(res)) c.String(200, string(res))
} }
func getUser(c *gin.Context) {
c.JSON(200, c.MustGet("user"))
}

80
routes/user.go Normal file
View File

@ -0,0 +1,80 @@
package routes
import (
"crypto/rand"
"encoding/base64"
"io"
"strings"
"time"
"github.com/ansible-semaphore/semaphore/database"
"github.com/ansible-semaphore/semaphore/models"
"github.com/gin-gonic/gin"
)
func getUser(c *gin.Context) {
c.JSON(200, c.MustGet("user"))
}
func getAPITokens(c *gin.Context) {
user := c.MustGet("user").(*models.User)
var tokens []models.APIToken
if _, err := database.Mysql.Select(&tokens, "select * from user__token where user_id=?", user.ID); err != nil {
panic(err)
}
c.JSON(200, tokens)
}
func createAPIToken(c *gin.Context) {
user := c.MustGet("user").(*models.User)
tokenID := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, tokenID); err != nil {
panic(err)
}
token := models.APIToken{
ID: strings.ToLower(base64.URLEncoding.EncodeToString(tokenID)),
Created: time.Now(),
UserID: user.ID,
Expired: false,
}
if err := database.Mysql.Insert(&token); err != nil {
panic(err)
}
temp_session := models.Session{
UserID: &user.ID,
}
if err := database.Redis.Set("token-session:"+token.ID, temp_session.Encode(), 0).Err(); err != nil {
panic(err)
}
c.JSON(201, token)
}
func expireAPIToken(c *gin.Context) {
user := c.MustGet("user").(*models.User)
tokenID := c.Param("token_id")
res, err := database.Mysql.Exec("update user__token set expired=1 where id=? and user_id=?", tokenID, user.ID)
if err != nil {
panic(err)
}
affected, err := res.RowsAffected()
if err != nil {
panic(err)
}
if affected > 0 {
// remove from redis
if err := database.Redis.Del("token-session:" + tokenID).Err(); err != nil {
panic(err)
}
}
c.AbortWithStatus(204)
}

View File

@ -45,12 +45,52 @@ definitions:
created: created:
type: string type: string
format: date-time format: date-time
APIToken:
type: object
properties:
id:
type: string
created:
type: string
format: date-time
expired:
type: boolean
user_id:
type: integer
Project:
type: object
properties:
id:
type: integer
name:
type: string
created:
type: string
format: date-time
securityDefinitions: # securityDefinitions:
cookie: # cookie:
type: apiKey # type: apiKey
name: Cookie # name: Cookie
in: header # in: header
# bearer:
# type: apiKey
# name: Authorization
# in: header
parameters:
project_id:
name: project_id
description: Project ID
in: path
type: integer
required: true
user_id:
name: user_id
description: User ID
in: path
type: integer
required: true
paths: paths:
/ping: /ping:
@ -61,8 +101,25 @@ paths:
description: Successful "PONG" reply description: Successful "PONG" reply
schema: schema:
$ref: "#/definitions/PONG" $ref: "#/definitions/PONG"
/ws:
get:
summary: Websocket handler
schemes:
- ws
- wss
responses:
200:
description: OK
# security:
# - cookie: []
# - bearer: []
# Authentication
/auth/login: /auth/login:
post: post:
tags:
- authentication
summary: Performs Login summary: Performs Login
description: | description: |
Upon success you will be logged in Upon success you will be logged in
@ -79,26 +136,162 @@ paths:
description: something in body is missing / is invalid description: something in body is missing / is invalid
/auth/logout: /auth/logout:
post: post:
tags:
- authentication
summary: Destroys current session summary: Destroys current session
responses: responses:
204: 204:
description: Your session was successfully nuked description: Your session was successfully nuked
/ws:
get: # User stuff
summary: Websocket handler
schemes:
- ws
- wss
responses:
200:
description: OK
security:
- cookie: []
/user: /user:
get: get:
tags:
- user
summary: Fetch logged in user summary: Fetch logged in user
responses: responses:
200: 200:
description: User description: User
schema: schema:
$ref: "#/definitions/User" $ref: "#/definitions/User"
/user/tokens:
get:
tags:
- authentication
- user
summary: Fetch API tokens for user
responses:
200:
description: API Tokens
schema:
type: array
items:
$ref: "#/definitions/APIToken"
post:
tags:
- authentication
- user
summary: Create an API token
responses:
201:
description: API Token
schema:
$ref: "#/definitions/APIToken"
/user/tokens/{api_token_id}:
parameters:
- name: api_token_id
in: path
type: string
required: true
delete:
tags:
- authentication
- user
summary: Expires API token
responses:
204:
description: Expired API Token
# Projects
/projects:
get:
tags:
- projects
summary: Get projects
responses:
200:
description: List of projects
schema:
type: array
items:
$ref: "#/definitions/Project"
post:
tags:
- projects
summary: Create a new project
parameters:
- name: Project
in: body
required: true
schema:
$ref: '#/definitions/Project'
responses:
200:
description: Created project
/project/{project_id}:
parameters:
- $ref: "#/parameters/project_id"
get:
tags:
- project
summary: Fetch project
responses:
200:
description: Project
schema:
$ref: "#/definitions/Project"
# User management
/project/{project_id}/users:
parameters:
- $ref: "#/parameters/project_id"
get:
tags:
- project
summary: Get users linked to project
responses:
200:
description: Users
schema:
type: array
items:
$ref: "#/definitions/User"
post:
tags:
- project
summary: Link user to project
parameters:
- name: User
in: body
required: true
schema:
type: object
properties:
user_id:
type: integer
format: userID
admin:
type: boolean
responses:
204:
description: User added
/project/{project_id}/users/{user_id}:
parameters:
- $ref: "#/parameters/project_id"
- $ref: "#/parameters/user_id"
delete:
tags:
- project
summary: Removes user from project
responses:
204:
description: User removed
/project/{project_id}/users/{user_id}/admin:
parameters:
- $ref: "#/parameters/project_id"
- $ref: "#/parameters/user_id"
post:
tags:
- project
summary: Makes user admin
responses:
204:
description: User made administrator
delete:
tags:
- project
summary: Revoke admin privileges
responses:
204:
description: User admin privileges revoked