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

View File

@ -1,4 +1,4 @@
package auth
package routes
import (
"database/sql"
@ -14,7 +14,7 @@ import (
sq "github.com/masterminds/squirrel"
)
func Login(c *gin.Context) {
func login(c *gin.Context) {
var login struct {
Auth string `json:"auth" binding:"required"`
Password string `json:"password" binding:"required"`
@ -56,7 +56,7 @@ func Login(c *gin.Context) {
session := c.MustGet("session").(models.Session)
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 {
panic(err)
}
@ -64,9 +64,9 @@ func Login(c *gin.Context) {
c.AbortWithStatus(204)
}
func Logout(c *gin.Context) {
func logout(c *gin.Context) {
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)
}

View File

@ -1,6 +1,8 @@
package projects
import (
"database/sql"
"github.com/ansible-semaphore/semaphore/database"
"github.com/ansible-semaphore/semaphore/models"
"github.com/ansible-semaphore/semaphore/util"
@ -9,6 +11,23 @@ import (
)
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()
}
@ -25,7 +44,6 @@ func GetKeys(c *gin.Context) {
}
query, args, _ := q.ToSql()
if _, err := database.Mysql.Select(&keys, query, args...); err != nil {
panic(err)
}
@ -57,17 +75,33 @@ func AddKey(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) {
project := c.MustGet("project").(models.Project)
keyID, err := util.GetIntParam("key_id", c)
if err != nil {
if err := c.Bind(&key); err != nil {
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)
}

View File

@ -1,6 +1,8 @@
package projects
import (
"database/sql"
"github.com/ansible-semaphore/semaphore/database"
"github.com/ansible-semaphore/semaphore/models"
"github.com/ansible-semaphore/semaphore/util"
@ -9,6 +11,23 @@ import (
)
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()
}
@ -44,15 +63,23 @@ func AddRepository(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) {
project := c.MustGet("project").(models.Project)
repositoryID, err := util.GetIntParam("repository_id", c)
if err != nil {
return
}
repository := c.MustGet("repository").(models.Repository)
if _, err := database.Mysql.Exec("delete from project__repository where project_id=? and id=?", project.ID, repositoryID); err != nil {
panic(err)

View File

@ -1,6 +1,8 @@
package projects
import (
"database/sql"
"github.com/ansible-semaphore/semaphore/database"
"github.com/ansible-semaphore/semaphore/models"
"github.com/ansible-semaphore/semaphore/util"
@ -9,6 +11,23 @@ import (
)
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()
}
@ -49,12 +68,9 @@ func AddUser(c *gin.Context) {
func RemoveUser(c *gin.Context) {
project := c.MustGet("project").(models.Project)
userID, err := util.GetIntParam("user_id", c)
if err != nil {
return
}
user := c.MustGet("projectUser").(models.User)
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)
}
@ -62,9 +78,18 @@ func RemoveUser(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" {
// 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 (
"strings"
"github.com/ansible-semaphore/semaphore/routes/auth"
"github.com/ansible-semaphore/semaphore/routes/projects"
"github.com/ansible-semaphore/semaphore/routes/sockets"
"github.com/ansible-semaphore/semaphore/routes/tasks"
@ -25,16 +24,22 @@ func Route(r *gin.Engine) {
api.Use(authentication)
func(api *gin.RouterGroup) {
api.POST("/login", auth.Login)
api.POST("/logout", auth.Logout)
api.POST("/login", login)
api.POST("/logout", logout)
}(api.Group("/auth"))
api.Use(MustAuthenticate)
api.GET("/ws", sockets.Handler)
api.GET("/user", getUser)
// api.PUT("/user", misc.UpdateUser)
func(api *gin.RouterGroup) {
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.POST("/projects", projects.AddProject)
@ -129,7 +134,3 @@ func servePublic(c *gin.Context) {
c.Writer.Header().Set("content-type", contentType)
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:
type: string
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:
cookie:
type: apiKey
name: Cookie
in: header
# securityDefinitions:
# cookie:
# type: apiKey
# name: Cookie
# 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:
/ping:
@ -61,8 +101,25 @@ paths:
description: Successful "PONG" reply
schema:
$ref: "#/definitions/PONG"
/ws:
get:
summary: Websocket handler
schemes:
- ws
- wss
responses:
200:
description: OK
# security:
# - cookie: []
# - bearer: []
# Authentication
/auth/login:
post:
tags:
- authentication
summary: Performs Login
description: |
Upon success you will be logged in
@ -79,26 +136,162 @@ paths:
description: something in body is missing / is invalid
/auth/logout:
post:
tags:
- authentication
summary: Destroys current session
responses:
204:
description: Your session was successfully nuked
/ws:
get:
summary: Websocket handler
schemes:
- ws
- wss
responses:
200:
description: OK
security:
- cookie: []
# User stuff
/user:
get:
tags:
- user
summary: Fetch logged in user
responses:
200:
description: User
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