1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-04 21:35:23 +02:00

feat(global): add authentication support with single admin account

This commit is contained in:
Anthony Lapenna 2016-12-15 16:33:47 +13:00 committed by GitHub
parent 1e5207517d
commit 4e77c72fa2
35 changed files with 1475 additions and 220 deletions

View file

@ -2,6 +2,8 @@ package main
import (
"crypto/tls"
"errors"
"github.com/gorilla/securecookie"
"log"
"net/http"
"net/url"
@ -15,6 +17,8 @@ type (
dataPath string
tlsConfig *tls.Config
templatesURL string
dataStore *dataStore
secret []byte
}
apiConfig struct {
@ -31,7 +35,21 @@ type (
}
)
const (
datastoreFileName = "portainer.db"
)
var (
errSecretKeyGeneration = errors.New("Unable to generate secret key to sign JWT")
)
func (a *api) run(settings *Settings) {
err := a.initDatabase()
if err != nil {
log.Fatal(err)
}
defer a.cleanUp()
handler := a.newHandler(settings)
log.Printf("Starting portainer on %s", a.bindAddress)
if err := http.ListenAndServe(a.bindAddress, handler); err != nil {
@ -39,12 +57,34 @@ func (a *api) run(settings *Settings) {
}
}
func (a *api) cleanUp() {
a.dataStore.cleanUp()
}
func (a *api) initDatabase() error {
dataStore, err := newDataStore(a.dataPath + "/" + datastoreFileName)
if err != nil {
return err
}
err = dataStore.initDataStore()
if err != nil {
return err
}
a.dataStore = dataStore
return nil
}
func newAPI(apiConfig apiConfig) *api {
endpointURL, err := url.Parse(apiConfig.Endpoint)
if err != nil {
log.Fatal(err)
}
secret := securecookie.GenerateRandomKey(32)
if secret == nil {
log.Fatal(errSecretKeyGeneration)
}
var tlsConfig *tls.Config
if apiConfig.TLSEnabled {
tlsConfig = newTLSConfig(apiConfig.TLSCACertPath, apiConfig.TLSCertPath, apiConfig.TLSKeyPath)
@ -57,5 +97,6 @@ func newAPI(apiConfig apiConfig) *api {
dataPath: apiConfig.DataPath,
tlsConfig: tlsConfig,
templatesURL: apiConfig.TemplatesURL,
secret: secret,
}
}

88
api/auth.go Normal file
View file

@ -0,0 +1,88 @@
package main
import (
"encoding/json"
"github.com/asaskevich/govalidator"
"golang.org/x/crypto/bcrypt"
"io/ioutil"
"log"
"net/http"
)
type (
credentials struct {
Username string `valid:"alphanum,required"`
Password string `valid:"length(8)"`
}
authResponse struct {
JWT string `json:"jwt"`
}
)
func hashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", nil
}
return string(hash), nil
}
func checkPasswordValidity(password string, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
// authHandler defines a handler function used to authenticate users
func (api *api) authHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
w.Header().Set("Allow", "POST")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, "Unable to parse request body", http.StatusBadRequest)
return
}
var credentials credentials
err = json.Unmarshal(body, &credentials)
if err != nil {
http.Error(w, "Unable to parse credentials", http.StatusBadRequest)
return
}
_, err = govalidator.ValidateStruct(credentials)
if err != nil {
http.Error(w, "Invalid credentials format", http.StatusBadRequest)
return
}
var username = credentials.Username
var password = credentials.Password
u, err := api.dataStore.getUserByUsername(username)
if err != nil {
log.Printf("User not found: %s", username)
http.Error(w, "User not found", http.StatusNotFound)
return
}
err = checkPasswordValidity(password, u.Password)
if err != nil {
log.Printf("Invalid credentials for user: %s", username)
http.Error(w, "Invalid credentials", http.StatusUnprocessableEntity)
return
}
token, err := api.generateJWTToken(username)
if err != nil {
log.Printf("Unable to generate JWT token: %s", err.Error())
http.Error(w, "Unable to generate JWT token", http.StatusInternalServerError)
return
}
response := authResponse{
JWT: token,
}
json.NewEncoder(w).Encode(response)
}

98
api/datastore.go Normal file
View file

@ -0,0 +1,98 @@
package main
import (
"encoding/json"
"errors"
"github.com/boltdb/bolt"
)
const (
userBucketName = "users"
)
type (
dataStore struct {
db *bolt.DB
}
userItem struct {
Username string `json:"username"`
Password string `json:"password,omitempty"`
}
)
var (
errUserNotFound = errors.New("User not found")
)
func (dataStore *dataStore) initDataStore() error {
return dataStore.db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte(userBucketName))
if err != nil {
return err
}
return nil
})
}
func (dataStore *dataStore) cleanUp() {
dataStore.db.Close()
}
func newDataStore(databasePath string) (*dataStore, error) {
db, err := bolt.Open(databasePath, 0600, nil)
if err != nil {
return nil, err
}
return &dataStore{
db: db,
}, nil
}
func (dataStore *dataStore) getUserByUsername(username string) (*userItem, error) {
var data []byte
err := dataStore.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
value := bucket.Get([]byte(username))
if value == nil {
return errUserNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var user userItem
err = json.Unmarshal(data, &user)
if err != nil {
return nil, err
}
return &user, nil
}
func (dataStore *dataStore) updateUser(user userItem) error {
buffer, err := json.Marshal(user)
if err != nil {
return err
}
err = dataStore.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
err = bucket.Put([]byte(user.Username), buffer)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
return nil
}

View file

@ -1,6 +1,7 @@
package main
import (
"github.com/gorilla/mux"
"golang.org/x/net/websocket"
"log"
"net/http"
@ -12,21 +13,35 @@ import (
// newHandler creates a new http.Handler with CSRF protection
func (a *api) newHandler(settings *Settings) http.Handler {
var (
mux = http.NewServeMux()
mux = mux.NewRouter()
fileHandler = http.FileServer(http.Dir(a.assetPath))
)
handler := a.newAPIHandler()
mux.Handle("/", fileHandler)
mux.Handle("/dockerapi/", http.StripPrefix("/dockerapi", handler))
mux.Handle("/ws/exec", websocket.Handler(a.execContainer))
mux.HandleFunc("/auth", a.authHandler)
mux.Handle("/users", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a.usersHandler(w, r)
}), a.authenticate, secureHeaders))
mux.Handle("/users/{username}", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a.userHandler(w, r)
}), a.authenticate, secureHeaders))
mux.Handle("/users/{username}/passwd", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a.userPasswordHandler(w, r)
}), a.authenticate, secureHeaders))
mux.HandleFunc("/users/admin/check", a.checkAdminHandler)
mux.HandleFunc("/users/admin/init", a.initAdminHandler)
mux.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) {
settingsHandler(w, r, settings)
})
mux.HandleFunc("/templates", func(w http.ResponseWriter, r *http.Request) {
templatesHandler(w, r, a.templatesURL)
})
// mux.PathPrefix("/dockerapi/").Handler(http.StripPrefix("/dockerapi", handler))
mux.PathPrefix("/dockerapi/").Handler(http.StripPrefix("/dockerapi", addMiddleware(handler, a.authenticate, secureHeaders)))
mux.PathPrefix("/").Handler(http.StripPrefix("/", fileHandler))
// CSRF protection is disabled for the moment
// CSRFHandler := newCSRFHandler(a.dataPath)
// return CSRFHandler(newCSRFWrapper(mux))

29
api/jwt.go Normal file
View file

@ -0,0 +1,29 @@
package main
import (
"github.com/dgrijalva/jwt-go"
"time"
)
type claims struct {
Username string `json:"username"`
jwt.StandardClaims
}
func (api *api) generateJWTToken(username string) (string, error) {
expireToken := time.Now().Add(time.Hour * 8).Unix()
claims := claims{
username,
jwt.StandardClaims{
ExpiresAt: expireToken,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, err := token.SignedString(api.secret)
if err != nil {
return "", err
}
return signedToken, nil
}

View file

@ -4,14 +4,19 @@ import (
"gopkg.in/alecthomas/kingpin.v2"
)
const (
// Version number of portainer API
Version = "1.10.2"
)
// main is the entry point of the program
func main() {
kingpin.Version("1.10.2")
kingpin.Version(Version)
var (
endpoint = kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String()
addr = kingpin.Flag("bind", "Address and port to serve Portainer").Default(":9000").Short('p').String()
assets = kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String()
data = kingpin.Flag("data", "Path to the data").Default(".").Short('d').String()
data = kingpin.Flag("data", "Path to the folder where the data is stored").Default("/data").Short('d').String()
tlsverify = kingpin.Flag("tlsverify", "TLS support").Default("false").Bool()
tlscacert = kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String()
tlscert = kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String()

65
api/middleware.go Normal file
View file

@ -0,0 +1,65 @@
package main
import (
"fmt"
"github.com/dgrijalva/jwt-go"
"net/http"
"strings"
)
func addMiddleware(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
for _, mw := range middleware {
h = mw(h)
}
return h
}
// authenticate provides Authentication middleware for handlers
func (api *api) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var token string
// Get token from the Authorization header
// format: Authorization: Bearer
tokens, ok := r.Header["Authorization"]
if ok && len(tokens) >= 1 {
token = tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
}
if token == "" {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
return nil, msg
}
return api.secret, nil
})
if err != nil {
http.Error(w, "Invalid JWT token", http.StatusUnauthorized)
return
}
if parsedToken == nil || !parsedToken.Valid {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
// context.Set(r, "user", parsedToken)
next.ServeHTTP(w, r)
return
})
}
// SecureHeaders adds secure headers to the API
func secureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Content-Type-Options", "nosniff")
w.Header().Add("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}

219
api/users.go Normal file
View file

@ -0,0 +1,219 @@
package main
import (
"encoding/json"
"github.com/gorilla/mux"
"io/ioutil"
"log"
"net/http"
)
type (
passwordCheckRequest struct {
Password string `json:"password"`
}
passwordCheckResponse struct {
Valid bool `json:"valid"`
}
initAdminRequest struct {
Password string `json:"password"`
}
)
// handle /users
// Allowed methods: POST
func (api *api) usersHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
w.Header().Set("Allow", "POST")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, "Unable to parse request body", http.StatusBadRequest)
return
}
var user userItem
err = json.Unmarshal(body, &user)
if err != nil {
http.Error(w, "Unable to parse user data", http.StatusBadRequest)
return
}
user.Password, err = hashPassword(user.Password)
if err != nil {
http.Error(w, "Unable to hash user password", http.StatusInternalServerError)
return
}
err = api.dataStore.updateUser(user)
if err != nil {
log.Printf("Unable to persist user: %s", err.Error())
http.Error(w, "Unable to persist user", http.StatusInternalServerError)
return
}
}
// handle /users/admin/check
// Allowed methods: POST
func (api *api) checkAdminHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
w.Header().Set("Allow", "GET")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
user, err := api.dataStore.getUserByUsername("admin")
if err == errUserNotFound {
log.Printf("User not found: %s", "admin")
http.Error(w, "User not found", http.StatusNotFound)
return
}
if err != nil {
log.Printf("Unable to retrieve user: %s", err.Error())
http.Error(w, "Unable to retrieve user", http.StatusInternalServerError)
return
}
user.Password = ""
json.NewEncoder(w).Encode(user)
}
// handle /users/admin/init
// Allowed methods: POST
func (api *api) initAdminHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
w.Header().Set("Allow", "POST")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, "Unable to parse request body", http.StatusBadRequest)
return
}
var requestData initAdminRequest
err = json.Unmarshal(body, &requestData)
if err != nil {
http.Error(w, "Unable to parse user data", http.StatusBadRequest)
return
}
user := userItem{
Username: "admin",
}
user.Password, err = hashPassword(requestData.Password)
if err != nil {
http.Error(w, "Unable to hash user password", http.StatusInternalServerError)
return
}
err = api.dataStore.updateUser(user)
if err != nil {
log.Printf("Unable to persist user: %s", err.Error())
http.Error(w, "Unable to persist user", http.StatusInternalServerError)
return
}
}
// handle /users/{username}
// Allowed methods: PUT, GET
func (api *api) userHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "PUT" {
body, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, "Unable to parse request body", http.StatusBadRequest)
return
}
var user userItem
err = json.Unmarshal(body, &user)
if err != nil {
http.Error(w, "Unable to parse user data", http.StatusBadRequest)
return
}
user.Password, err = hashPassword(user.Password)
if err != nil {
http.Error(w, "Unable to hash user password", http.StatusInternalServerError)
return
}
err = api.dataStore.updateUser(user)
if err != nil {
log.Printf("Unable to persist user: %s", err.Error())
http.Error(w, "Unable to persist user", http.StatusInternalServerError)
return
}
} else if r.Method == "GET" {
vars := mux.Vars(r)
username := vars["username"]
user, err := api.dataStore.getUserByUsername(username)
if err == errUserNotFound {
log.Printf("User not found: %s", username)
http.Error(w, "User not found", http.StatusNotFound)
return
}
if err != nil {
log.Printf("Unable to retrieve user: %s", err.Error())
http.Error(w, "Unable to retrieve user", http.StatusInternalServerError)
return
}
user.Password = ""
json.NewEncoder(w).Encode(user)
} else {
w.Header().Set("Allow", "PUT, GET")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
}
// handle /users/{username}/passwd
// Allowed methods: POST
func (api *api) userPasswordHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
w.Header().Set("Allow", "POST")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
vars := mux.Vars(r)
username := vars["username"]
body, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, "Unable to parse request body", http.StatusBadRequest)
return
}
var data passwordCheckRequest
err = json.Unmarshal(body, &data)
if err != nil {
http.Error(w, "Unable to parse user data", http.StatusBadRequest)
return
}
user, err := api.dataStore.getUserByUsername(username)
if err != nil {
log.Printf("Unable to retrieve user: %s", err.Error())
http.Error(w, "Unable to retrieve user", http.StatusInternalServerError)
return
}
valid := true
err = checkPasswordValidity(data.Password, user.Password)
if err != nil {
valid = false
}
response := passwordCheckResponse{
Valid: valid,
}
json.NewEncoder(w).Encode(response)
}