Adds LDAP auth support

This commit is contained in:
Jamie Curnow
2024-11-02 21:36:07 +10:00
parent 8434a2d1fa
commit a277a5d167
54 changed files with 765 additions and 306 deletions

View File

@ -3,97 +3,244 @@ package handler
import (
"encoding/json"
"net/http"
h "npm/internal/api/http"
"npm/internal/errors"
"npm/internal/logger"
"slices"
"time"
c "npm/internal/api/context"
h "npm/internal/api/http"
"npm/internal/entity/auth"
"npm/internal/entity/setting"
"npm/internal/entity/user"
"npm/internal/errors"
"npm/internal/logger"
njwt "npm/internal/jwt"
"gorm.io/gorm"
)
type setAuthModel struct {
// The json tags are required, as the change password form decodes into this object
Type string `json:"type"`
Secret string `json:"secret"`
CurrentSecret string `json:"current_secret"`
// tokenPayload is the structure we expect from a incoming login request
type tokenPayload struct {
Type string `json:"type"`
Identity string `json:"identity"`
Secret string `json:"secret"`
}
// SetAuth sets a auth method. This can be used for "me" and `2` for example
// Route: POST /users/:userID/auth
func SetAuth() func(http.ResponseWriter, *http.Request) {
// GetAuthConfig is anonymous and returns the types of authentication
// enabled for this site
func GetAuthConfig() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
val, err := setting.GetAuthMethods()
if err == gorm.ErrRecordNotFound {
h.ResultResponseJSON(w, r, http.StatusOK, nil)
return
} else if err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
h.ResultResponseJSON(w, r, http.StatusOK, val)
}
}
// NewToken Also known as a Login, requesting a new token with credentials
// Route: POST /auth
func NewToken() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// Read the bytes from the body
bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte)
var newAuth setAuthModel
err := json.Unmarshal(bodyBytes, &newAuth)
var payload tokenPayload
err := json.Unmarshal(bodyBytes, &payload)
if err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil)
return
}
userID, isSelf, userIDErr := getUserIDFromRequest(r)
if userIDErr != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, userIDErr.Error(), nil)
// Check that this auth type is enabled
if authMethods, err := setting.GetAuthMethods(); err == gorm.ErrRecordNotFound {
h.ResultResponseJSON(w, r, http.StatusOK, nil)
return
} else if err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
} else if !slices.Contains(authMethods, payload.Type) {
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidAuthType.Error(), nil)
return
}
// Load user
thisUser, thisUserErr := user.GetByID(userID)
if thisUserErr == gorm.ErrRecordNotFound {
h.NotFound(w, r)
return
} else if thisUserErr != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, thisUserErr.Error(), nil)
return
switch payload.Type {
case "ldap":
newTokenLDAP(w, r, payload)
case "oidc":
newTokenOIDC(w, r, payload)
case "local":
newTokenLocal(w, r, payload)
}
}
}
func newTokenLocal(w http.ResponseWriter, r *http.Request, payload tokenPayload) {
// Find user by email
userObj, userErr := user.GetByEmail(payload.Identity)
if userErr != nil {
logger.Debug("%s: %s", errors.ErrInvalidLogin.Error(), userErr.Error())
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil)
return
}
if userObj.IsDisabled {
h.ResultErrorJSON(w, r, http.StatusUnauthorized, errors.ErrUserDisabled.Error(), nil)
return
}
// Get Auth
authObj, authErr := auth.GetByUserIDType(userObj.ID, payload.Type)
if authErr != nil {
logger.Debug("%s: %s", errors.ErrInvalidLogin.Error(), authErr.Error())
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil)
return
}
// Verify Auth
validateErr := authObj.ValidateSecret(payload.Secret)
if validateErr != nil {
logger.Debug("%s: %s", errors.ErrInvalidLogin.Error(), validateErr.Error())
// Sleep for 1 second to prevent brute force password guessing
time.Sleep(time.Second)
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil)
return
}
if response, err := njwt.Generate(&userObj, false); err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
} else {
h.ResultResponseJSON(w, r, http.StatusOK, response)
}
}
func newTokenLDAP(w http.ResponseWriter, r *http.Request, payload tokenPayload) {
// Get LDAP settings
ldapSettings, err := setting.GetLDAPSettings()
if err != nil {
logger.Error("LDAP settings not found", err)
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
return
}
// Lets try to authenticate with LDAP
ldapUser, err := auth.LDAPAuthenticate(payload.Identity, payload.Secret)
if err != nil {
logger.Error("LDAP Auth Error", err)
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil)
return
}
// Get Auth by identity
authObj, authErr := auth.GetByIdenityType(ldapUser.Username, payload.Type)
if authErr == gorm.ErrRecordNotFound {
// Auth is not found for this identity. We can create it
if !ldapSettings.AutoCreateUser {
// LDAP Login was successful, but user does not have an auth record
// and auto create is disabled. Showing account disabled error
// for the time being
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrUserDisabled.Error(), nil)
return
}
// Attempt to find user by email
foundUser, err := user.GetByEmail(ldapUser.Email)
if err == gorm.ErrRecordNotFound {
// User not found, create user
foundUser, err = user.CreateFromLDAPUser(ldapUser)
if err != nil {
logger.Error("user.CreateFromLDAPUser", err)
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
return
}
logger.Info("Created user from LDAP: %s, %s", ldapUser.Username, foundUser.Email)
} else if err != nil {
logger.Error("user.GetByEmail", err)
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
return
}
// Create auth record and attach to this user
authObj = auth.Model{
UserID: foundUser.ID,
Type: auth.TypeLDAP,
Identity: ldapUser.Username,
}
if err := authObj.Save(); err != nil {
logger.Error("auth.Save", err)
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
return
}
logger.Info("Created LDAP auth for user: %s, %s", ldapUser.Username, foundUser.Email)
} else if authErr != nil {
logger.Error("auth.GetByIdenityType", err)
h.ResultErrorJSON(w, r, http.StatusInternalServerError, authErr.Error(), nil)
return
}
userObj, userErr := user.GetByID(authObj.UserID)
if userErr != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, userErr.Error(), nil)
return
}
if userObj.IsDisabled {
h.ResultErrorJSON(w, r, http.StatusUnauthorized, errors.ErrUserDisabled.Error(), nil)
return
}
if response, err := njwt.Generate(&userObj, false); err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
} else {
h.ResultResponseJSON(w, r, http.StatusOK, response)
}
}
func newTokenOIDC(w http.ResponseWriter, r *http.Request, _ tokenPayload) {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, "NOT YET SUPPORTED", nil)
}
// RefreshToken an existing token by given them a new one with the same claims
// Route: POST /auth/refresh
func RefreshToken() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Use your own methods to verify an existing user is
// able to refresh their token and then give them a new one
userObj, _ := user.GetByEmail("jc@jc21.com")
if response, err := njwt.Generate(&userObj, false); err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
} else {
h.ResultResponseJSON(w, r, http.StatusOK, response)
}
}
}
// NewSSEToken will generate and return a very short lived token for
// use by the /sse/* endpoint. It requires an app token to generate this
// Route: POST /auth/sse
func NewSSEToken() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(c.UserIDCtxKey).(uint)
// Find user
userObj, userErr := user.GetByID(userID)
if userErr != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil)
return
}
if userObj.IsDisabled {
h.ResultErrorJSON(w, r, http.StatusUnauthorized, errors.ErrUserDisabled.Error(), nil)
return
}
if response, err := njwt.Generate(&userObj, true); err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
} else {
h.ResultResponseJSON(w, r, http.StatusOK, response)
}
if thisUser.IsSystem {
h.ResultErrorJSON(w, r, http.StatusBadRequest, "Cannot set password for system user", nil)
return
}
// Load existing auth for user
userAuth, userAuthErr := auth.GetByUserIDType(userID, newAuth.Type)
if userAuthErr != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, userAuthErr.Error(), nil)
return
}
if isSelf {
// confirm that the current_secret given is valid for the one stored in the database
validateErr := userAuth.ValidateSecret(newAuth.CurrentSecret)
if validateErr != nil {
logger.Debug("%s: %s", "Password change: current password was incorrect", validateErr.Error())
// Sleep for 1 second to prevent brute force password guessing
time.Sleep(time.Second)
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrCurrentPasswordInvalid.Error(), nil)
return
}
}
if newAuth.Type == auth.TypePassword {
err := userAuth.SetPassword(newAuth.Secret)
if err != nil {
logger.Error("SetPasswordError", err)
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
}
}
if err = userAuth.Save(); err != nil {
logger.Error("AuthSaveError", err)
h.ResultErrorJSON(w, r, http.StatusInternalServerError, "Unable to save Authentication for User", nil)
return
}
userAuth.Secret = ""
// todo: add to audit-log
h.ResultResponseJSON(w, r, http.StatusOK, userAuth)
}
}

View File

@ -64,6 +64,12 @@ func CreateSetting() func(http.ResponseWriter, *http.Request) {
return
}
// Check if the setting already exists
if _, err := setting.GetByName(newSetting.Name); err == nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, fmt.Sprintf("Setting with name '%s' already exists", newSetting.Name), nil)
return
}
if err = newSetting.Save(); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, fmt.Sprintf("Unable to save Setting: %s", err.Error()), nil)
return
@ -75,6 +81,7 @@ func CreateSetting() func(http.ResponseWriter, *http.Request) {
// UpdateSetting updates a setting
// Route: PUT /settings/{name}
// TODO: Add validation for the setting value, for system settings they should be validated against the setting name and type
func UpdateSetting() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
settingName := chi.URLParam(r, "name")
@ -85,13 +92,12 @@ func UpdateSetting() func(http.ResponseWriter, *http.Request) {
h.NotFound(w, r)
case nil:
bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte)
err := json.Unmarshal(bodyBytes, &setting)
if err != nil {
if err := json.Unmarshal(bodyBytes, &setting); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil)
return
}
if err = setting.Save(); err != nil {
if err := setting.Save(); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}

View File

@ -1,116 +0,0 @@
package handler
import (
"encoding/json"
"net/http"
h "npm/internal/api/http"
"npm/internal/errors"
"npm/internal/logger"
"time"
c "npm/internal/api/context"
"npm/internal/entity/auth"
"npm/internal/entity/user"
njwt "npm/internal/jwt"
)
// tokenPayload is the structure we expect from a incoming login request
type tokenPayload struct {
Type string `json:"type"`
Identity string `json:"identity"`
Secret string `json:"secret"`
}
// NewToken Also known as a Login, requesting a new token with credentials
// Route: POST /tokens
func NewToken() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// Read the bytes from the body
bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte)
var payload tokenPayload
err := json.Unmarshal(bodyBytes, &payload)
if err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil)
return
}
// Find user
userObj, userErr := user.GetByEmail(payload.Identity)
if userErr != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil)
return
}
if userObj.IsDisabled {
h.ResultErrorJSON(w, r, http.StatusUnauthorized, errors.ErrUserDisabled.Error(), nil)
return
}
// Get Auth
authObj, authErr := auth.GetByUserIDType(userObj.ID, payload.Type)
if authErr != nil {
logger.Debug("%s: %s", errors.ErrInvalidLogin.Error(), authErr.Error())
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil)
return
}
// Verify Auth
validateErr := authObj.ValidateSecret(payload.Secret)
if validateErr != nil {
logger.Debug("%s: %s", errors.ErrInvalidLogin.Error(), validateErr.Error())
// Sleep for 1 second to prevent brute force password guessing
time.Sleep(time.Second)
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil)
return
}
if response, err := njwt.Generate(&userObj, false); err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
} else {
h.ResultResponseJSON(w, r, http.StatusOK, response)
}
}
}
// RefreshToken an existing token by given them a new one with the same claims
// Route: GET /tokens
func RefreshToken() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Use your own methods to verify an existing user is
// able to refresh their token and then give them a new one
userObj, _ := user.GetByEmail("jc@jc21.com")
if response, err := njwt.Generate(&userObj, false); err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
} else {
h.ResultResponseJSON(w, r, http.StatusOK, response)
}
}
}
// NewSSEToken will generate and return a very short lived token for
// use by the /sse/* endpoint. It requires an app token to generate this
// Route: POST /tokens/sse
func NewSSEToken() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(c.UserIDCtxKey).(uint)
// Find user
userObj, userErr := user.GetByID(userID)
if userErr != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil)
return
}
if userObj.IsDisabled {
h.ResultErrorJSON(w, r, http.StatusUnauthorized, errors.ErrUserDisabled.Error(), nil)
return
}
if response, err := njwt.Generate(&userObj, true); err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
} else {
h.ResultResponseJSON(w, r, http.StatusOK, response)
}
}
}

View File

@ -3,6 +3,7 @@ package handler
import (
"encoding/json"
"net/http"
"time"
c "npm/internal/api/context"
h "npm/internal/api/http"
@ -17,6 +18,13 @@ import (
"gorm.io/gorm"
)
type setAuthModel struct {
// The json tags are required, as the change password form decodes into this object
Type string `json:"type"`
Secret string `json:"secret"`
CurrentSecret string `json:"current_secret"`
}
// GetUsers returns all users
// Route: GET /users
func GetUsers() func(http.ResponseWriter, *http.Request) {
@ -188,7 +196,7 @@ func CreateUser() func(http.ResponseWriter, *http.Request) {
// newUser has been saved, now save their auth
if newUser.Auth.Secret != "" && newUser.Auth.ID == 0 {
newUser.Auth.UserID = newUser.ID
if newUser.Auth.Type == auth.TypePassword {
if newUser.Auth.Type == auth.TypeLocal {
err = newUser.Auth.SetPassword(newUser.Auth.Secret)
if err != nil {
logger.Error("SetPasswordError", err)
@ -247,3 +255,79 @@ func getUserIDFromRequest(r *http.Request) (uint, bool, error) {
}
return userID, self, nil
}
// SetAuth sets a auth method. This can be used for "me" and `2` for example
// Route: POST /users/:userID/auth
func SetAuth() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte)
var newAuth setAuthModel
err := json.Unmarshal(bodyBytes, &newAuth)
if err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil)
return
}
userID, isSelf, userIDErr := getUserIDFromRequest(r)
if userIDErr != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, userIDErr.Error(), nil)
return
}
// Load user
thisUser, thisUserErr := user.GetByID(userID)
if thisUserErr == gorm.ErrRecordNotFound {
h.NotFound(w, r)
return
} else if thisUserErr != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, thisUserErr.Error(), nil)
return
}
if thisUser.IsSystem {
h.ResultErrorJSON(w, r, http.StatusBadRequest, "Cannot set password for system user", nil)
return
}
// Load existing auth for user
userAuth, userAuthErr := auth.GetByUserIDType(userID, newAuth.Type)
if userAuthErr != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, userAuthErr.Error(), nil)
return
}
if isSelf {
// confirm that the current_secret given is valid for the one stored in the database
validateErr := userAuth.ValidateSecret(newAuth.CurrentSecret)
if validateErr != nil {
logger.Debug("%s: %s", "Password change: current password was incorrect", validateErr.Error())
// Sleep for 1 second to prevent brute force password guessing
time.Sleep(time.Second)
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrCurrentPasswordInvalid.Error(), nil)
return
}
}
if newAuth.Type == auth.TypeLocal {
err := userAuth.SetPassword(newAuth.Secret)
if err != nil {
logger.Error("SetPasswordError", err)
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
}
}
if err = userAuth.Save(); err != nil {
logger.Error("AuthSaveError", err)
h.ResultErrorJSON(w, r, http.StatusInternalServerError, "Unable to save Authentication for User", nil)
return
}
userAuth.Secret = ""
// todo: add to audit-log
h.ResultResponseJSON(w, r, http.StatusOK, userAuth)
}
}

View File

@ -4,9 +4,10 @@ import (
"io"
"net/http"
"net/http/httptest"
"testing"
"npm/internal/entity/user"
"npm/internal/model"
"testing"
"github.com/qri-io/jsonschema"
"github.com/stretchr/testify/assert"
@ -36,9 +37,8 @@ func TestResultResponseJSON(t *testing.T) {
ModelBase: model.ModelBase{ID: 10},
Email: "me@example.com",
Name: "John Doe",
Nickname: "Jonny",
},
want: "{\"result\":{\"id\":10,\"created_at\":0,\"updated_at\":0,\"name\":\"John Doe\",\"nickname\":\"Jonny\",\"email\":\"me@example.com\",\"is_disabled\":false,\"gravatar_url\":\"\"}}",
want: "{\"result\":{\"id\":10,\"created_at\":0,\"updated_at\":0,\"name\":\"John Doe\",\"email\":\"me@example.com\",\"is_disabled\":false,\"gravatar_url\":\"\"}}",
},
{
name: "error response",

View File

@ -74,12 +74,13 @@ func applyRoutes(r chi.Router) chi.Router {
r.With(middleware.EnforceSetup(), middleware.Enforce()).
Get("/config", handler.Config())
// Tokens
r.With(middleware.EnforceSetup()).Route("/tokens", func(r chi.Router) {
// Auth
r.With(middleware.EnforceSetup()).Route("/auth", func(r chi.Router) {
r.Get("/", handler.GetAuthConfig())
r.With(middleware.EnforceRequestSchema(schema.GetToken())).
Post("/", handler.NewToken())
r.With(middleware.Enforce()).
Get("/", handler.RefreshToken())
Post("/refresh", handler.RefreshToken())
r.With(middleware.Enforce()).
Post("/sse", handler.NewSSEToken())
})

View File

@ -64,6 +64,9 @@ const anyType = `
},
{
"type": "integer"
},
{
"type": "string"
}
]
}

View File

@ -16,7 +16,6 @@ func CreateUser() string {
],
"properties": {
"name": %s,
"nickname": %s,
"email": %s,
"is_disabled": {
"type": "boolean"
@ -30,7 +29,7 @@ func CreateUser() string {
"properties": {
"type": {
"type": "string",
"pattern": "^password$"
"pattern": "^local$"
},
"secret": %s
}
@ -38,5 +37,5 @@ func CreateUser() string {
"capabilities": %s
}
}
`, stringMinMax(2, 100), stringMinMax(2, 100), stringMinMax(5, 150), stringMinMax(8, 255), capabilties())
`, stringMinMax(2, 50), stringMinMax(5, 150), stringMinMax(8, 255), capabilties())
}

View File

@ -18,7 +18,7 @@ func GetToken() string {
"properties": {
"type": {
"type": "string",
"pattern": "^password$"
"enum": ["local", "ldap", "oidc"]
},
"identity": %s,
"secret": %s

View File

@ -3,6 +3,7 @@ package schema
import "fmt"
// SetAuth is the schema for incoming data validation
// Only local auth is supported for setting a password
func SetAuth() string {
return fmt.Sprintf(`
{
@ -15,7 +16,7 @@ func SetAuth() string {
"properties": {
"type": {
"type": "string",
"pattern": "^password$"
"pattern": "^local$"
},
"secret": %s,
"current_secret": %s

View File

@ -11,7 +11,6 @@ func UpdateUser() string {
"minProperties": 1,
"properties": {
"name": %s,
"nickname": %s,
"email": %s,
"is_disabled": {
"type": "boolean"
@ -19,5 +18,5 @@ func UpdateUser() string {
"capabilities": %s
}
}
`, stringMinMax(2, 100), stringMinMax(2, 100), stringMinMax(5, 150), capabilties())
`, stringMinMax(2, 50), stringMinMax(5, 150), capabilties())
}

View File

@ -40,7 +40,7 @@ func (s *testsuite) SetupTest() {
}).AddRow(
10,
100,
TypePassword,
TypeLocal,
"abc123",
)
}
@ -54,7 +54,7 @@ func TestExampleTestSuite(t *testing.T) {
func assertModel(t *testing.T, m Model) {
assert.Equal(t, uint(10), m.ID)
assert.Equal(t, uint(100), m.UserID)
assert.Equal(t, TypePassword, m.Type)
assert.Equal(t, TypeLocal, m.Type)
assert.Equal(t, "abc123", m.Secret)
}
@ -83,10 +83,10 @@ func (s *testsuite) TestGetByUserIDType() {
s.mock.
ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "auth" WHERE user_id = $1 AND type = $2 AND "auth"."is_deleted" = $3 ORDER BY "auth"."id" LIMIT $4`)).
WithArgs(100, TypePassword, 0, 1).
WithArgs(100, TypeLocal, 0, 1).
WillReturnRows(s.singleRow)
m, err := GetByUserIDType(100, TypePassword)
m, err := GetByUserIDType(100, TypeLocal)
require.NoError(s.T(), err)
require.NoError(s.T(), s.mock.ExpectationsWereMet())
assertModel(s.T(), m)
@ -103,7 +103,7 @@ func (s *testsuite) TestSave() {
sqlmock.AnyArg(),
0,
100,
TypePassword,
TypeLocal,
"abc123",
).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("11"))
@ -112,7 +112,7 @@ func (s *testsuite) TestSave() {
// New model
m := Model{
UserID: 100,
Type: TypePassword,
Type: TypeLocal,
Secret: "abc123",
}
err := m.Save()
@ -127,7 +127,7 @@ func (s *testsuite) TestSetPassword() {
m := Model{UserID: 100}
err := m.SetPassword("abc123")
require.NoError(s.T(), err)
assert.Equal(s.T(), TypePassword, m.Type)
assert.Equal(s.T(), TypeLocal, m.Type)
assert.Greater(s.T(), len(m.Secret), 15)
}
@ -143,10 +143,10 @@ func (s *testsuite) TestValidateSecret() {
require.NoError(s.T(), err)
err = m.ValidateSecret("this is not the password")
assert.NotNil(s.T(), err)
assert.Equal(s.T(), "Invalid Password", err.Error())
assert.Equal(s.T(), "Invalid Credentials", err.Error())
m.Type = "not a valid type"
err = m.ValidateSecret("abc123")
assert.NotNil(s.T(), err)
assert.Equal(s.T(), "Could not validate Secret, auth type is not a Password", err.Error())
assert.Equal(s.T(), "Could not validate Secret, auth type is not Local", err.Error())
}

View File

@ -0,0 +1,96 @@
package auth
import (
"encoding/json"
"fmt"
"strings"
"npm/internal/entity/setting"
"npm/internal/logger"
ldap3 "github.com/go-ldap/ldap/v3"
"github.com/rotisserie/eris"
)
// LDAPUser is the LDAP User
type LDAPUser struct {
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
}
// LDAPAuthenticate will use ldap to authenticate with user/pass
func LDAPAuthenticate(identity, password string) (*LDAPUser, error) {
ldapSettings, err := setting.GetLDAPSettings()
if err != nil {
return nil, err
}
dn := strings.Replace(ldapSettings.UserDN, "{{USERNAME}}", identity, 1)
conn, err := ldapConnect(ldapSettings.Host, dn, password)
if err != nil {
return nil, err
}
// nolint: errcheck, gosec
defer conn.Close()
return ldapSearchUser(conn, ldapSettings, identity)
}
// Attempt ldap connection
func ldapConnect(host, dn, password string) (*ldap3.Conn, error) {
var conn *ldap3.Conn
var err error
if conn, err = ldap3.DialURL(fmt.Sprintf("ldap://%s", host)); err != nil {
logger.Error("LdapError", err)
return nil, err
}
logger.Debug("LDAP Logging in with: %s", dn)
if err := conn.Bind(dn, password); err != nil {
if !strings.Contains(err.Error(), "Invalid Credentials") {
logger.Error("LDAPAuthError", err)
}
// nolint: gosec, errcheck
conn.Close()
return nil, err
}
logger.Debug("LDAP Login Successful")
return conn, nil
}
func ldapSearchUser(l *ldap3.Conn, ldapSettings setting.LDAPSettings, username string) (*LDAPUser, error) {
// Search for the given username
searchRequest := ldap3.NewSearchRequest(
ldapSettings.BaseDN,
ldap3.ScopeWholeSubtree,
ldap3.NeverDerefAliases,
0,
0,
false,
strings.Replace(ldapSettings.SelfFilter, "{{USERNAME}}", username, 1),
nil, // []string{"name"},
nil,
)
sr, err := l.Search(searchRequest)
if err != nil {
logger.Error("LdapError", err)
return nil, err
}
if len(sr.Entries) < 1 {
return nil, eris.New("No user found in LDAP search")
} else if len(sr.Entries) > 1 {
j, _ := json.Marshal(sr)
logger.Debug("LDAP Search Results: %s", j)
return nil, eris.Errorf("Too many LDAP results returned in LDAP search: %d", len(sr.Entries))
}
return &LDAPUser{
Username: strings.ToLower(username),
Name: sr.Entries[0].GetAttributeValue(ldapSettings.NameProperty),
Email: strings.ToLower(sr.Entries[0].GetAttributeValue(ldapSettings.EmailProperty)),
}, nil
}

View File

@ -11,7 +11,7 @@ func GetByID(id int) (Model, error) {
return m, err
}
// GetByUserIDType finds a user by email
// GetByUserIDType finds a user by id and type
func GetByUserIDType(userID uint, authType string) (Model, error) {
var auth Model
db := database.GetDB()
@ -21,3 +21,14 @@ func GetByUserIDType(userID uint, authType string) (Model, error) {
First(&auth)
return auth, result.Error
}
// GetByUserIDType finds a user by id and type
func GetByIdenityType(identity string, authType string) (Model, error) {
var auth Model
db := database.GetDB()
result := db.
Where("identity = ?", identity).
Where("type = ?", authType).
First(&auth)
return auth, result.Error
}

View File

@ -8,17 +8,20 @@ import (
"golang.org/x/crypto/bcrypt"
)
// Auth types
const (
// TypePassword is the Password Type
TypePassword = "password"
TypeLocal = "local"
TypeLDAP = "ldap"
TypeOIDC = "oidc"
)
// Model is the model
type Model struct {
model.ModelBase
UserID uint `json:"user_id" gorm:"column:user_id"`
Type string `json:"type" gorm:"column:type;default:password"`
Secret string `json:"secret,omitempty" gorm:"column:secret"`
UserID uint `json:"user_id" gorm:"column:user_id"`
Type string `json:"type" gorm:"column:type;default:local"`
Identity string `json:"identity,omitempty" gorm:"column:identity"`
Secret string `json:"secret,omitempty" gorm:"column:secret"`
}
// TableName overrides the table name used by gorm
@ -48,7 +51,7 @@ func (m *Model) SetPassword(password string) error {
return err
}
m.Type = TypePassword
m.Type = TypeLocal
m.Secret = string(hash)
return nil
@ -56,13 +59,13 @@ func (m *Model) SetPassword(password string) error {
// ValidateSecret will check if a given secret matches the encrypted secret
func (m *Model) ValidateSecret(secret string) error {
if m.Type != TypePassword {
return eris.New("Could not validate Secret, auth type is not a Password")
if m.Type != TypeLocal {
return eris.New("Could not validate Secret, auth type is not Local")
}
err := bcrypt.CompareHashAndPassword([]byte(m.Secret), []byte(secret))
if err != nil {
return eris.New("Invalid Password")
return eris.New("Invalid Credentials")
}
return nil

View File

@ -0,0 +1,20 @@
package setting
import (
"encoding/json"
)
// GetAuthMethods returns the authentication methods enabled for this site
func GetAuthMethods() ([]string, error) {
var l []string
var m Model
if err := m.LoadByName("auth-methods"); err != nil {
return l, err
}
if err := json.Unmarshal([]byte(m.Value.String()), &l); err != nil {
return l, err
}
return l, nil
}

View File

@ -0,0 +1,32 @@
package setting
import (
"encoding/json"
)
// LDAPSettings are the settings for LDAP that come from
// the `ldap-auth` setting value
type LDAPSettings struct {
Host string `json:"host"`
BaseDN string `json:"base_dn"`
UserDN string `json:"user_dn"`
EmailProperty string `json:"email_property"`
NameProperty string `json:"name_property"`
SelfFilter string `json:"self_filter"`
AutoCreateUser bool `json:"auto_create_user"`
}
// GetLDAPSettings will return the LDAP settings
func GetLDAPSettings() (LDAPSettings, error) {
var l LDAPSettings
var m Model
if err := m.LoadByName("ldap-auth"); err != nil {
return l, err
}
if err := json.Unmarshal([]byte(m.Value.String()), &l); err != nil {
return l, err
}
return l, nil
}

View File

@ -41,14 +41,12 @@ func (s *testsuite) SetupTest() {
s.singleRow = sqlmock.NewRows([]string{
"id",
"name",
"nickname",
"email",
"is_disabled",
"is_system",
}).AddRow(
10,
"John Doe",
"Jonny",
"jon@example.com",
false,
false,
@ -74,14 +72,12 @@ func (s *testsuite) SetupTest() {
s.listRows = sqlmock.NewRows([]string{
"id",
"name",
"nickname",
"email",
"is_disabled",
"is_system",
}).AddRow(
10,
"John Doe",
"Jonny",
"jon@example.com",
false,
false,
@ -104,7 +100,6 @@ func TestExampleTestSuite(t *testing.T) {
func assertModel(t *testing.T, m Model) {
assert.Equal(t, uint(10), m.ID)
assert.Equal(t, "John Doe", m.Name)
assert.Equal(t, "Jonny", m.Nickname)
assert.Equal(t, "jon@example.com", m.Email)
assert.Equal(t, false, m.IsDisabled)
assert.Equal(t, false, m.IsSystem)
@ -182,7 +177,7 @@ func (s *testsuite) TestSave() {
WillReturnRows(s.singleRow)
s.mock.ExpectBegin()
s.mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "user" ("created_at","updated_at","is_deleted","name","nickname","email","is_disabled","is_system") VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING "id"`)).
s.mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "user" ("created_at","updated_at","is_deleted","name","email","is_disabled","is_system") VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING "id"`)).
WithArgs(
sqlmock.AnyArg(),
sqlmock.AnyArg(),
@ -199,7 +194,6 @@ func (s *testsuite) TestSave() {
// New model, as system
m := Model{
Name: "John Doe",
Nickname: "Jonny",
Email: "JON@example.com", // mixed case on purpose
IsSystem: true,
}

View File

@ -2,8 +2,10 @@ package user
import (
"fmt"
"npm/internal/database"
"npm/internal/entity"
"npm/internal/entity/auth"
"npm/internal/logger"
"npm/internal/model"
)
@ -104,3 +106,14 @@ func GetCapabilities(userID uint) ([]string, error) {
}
return capabilities, nil
}
// CreateFromLDAPUser will create a user from an LDAP user object
func CreateFromLDAPUser(ldapUser *auth.LDAPUser) (Model, error) {
user := Model{
Email: ldapUser.Email,
Name: ldapUser.Name,
}
err := user.Save()
user.generateGravatar()
return user, err
}

View File

@ -18,7 +18,6 @@ import (
type Model struct {
model.ModelBase
Name string `json:"name" gorm:"column:name" filter:"name,string"`
Nickname string `json:"nickname" gorm:"column:nickname" filter:"nickname,string"`
Email string `json:"email" gorm:"column:email" filter:"email,email"`
IsDisabled bool `json:"is_disabled" gorm:"column:is_disabled" filter:"is_disabled,boolean"`
IsSystem bool `json:"is_system,omitempty" gorm:"column:is_system" filter:"is_system,boolean"`

View File

@ -10,6 +10,7 @@ var (
ErrDatabaseUnavailable = eris.New("database-unavailable")
ErrDuplicateEmailUser = eris.New("email-already-exists")
ErrInvalidLogin = eris.New("invalid-login-credentials")
ErrInvalidAuthType = eris.New("invalid-auth-type")
ErrUserDisabled = eris.New("user-disabled")
ErrSystemUserReadonly = eris.New("cannot-save-system-users")
ErrValidationFailed = eris.New("request-failed-validation")

View File

@ -7,7 +7,7 @@ type Filter struct {
Value []string `json:"value"`
}
// FilterMapValue ...
// FilterMapValue is the structure of a filter map value
type FilterMapValue struct {
Type string
Field string

View File

@ -15,7 +15,7 @@ type Sort struct {
Direction string `json:"direction"`
}
// GetSort ...
// GetSort is the sort array
func (p *PageInfo) GetSort(def Sort) []Sort {
if p.Sort == nil {
return []Sort{def}

View File

@ -18,7 +18,7 @@ func TestPageInfoGetSort(t *testing.T) {
Direction: "asc",
}
defined := Sort{
Field: "nickname",
Field: "email",
Direction: "desc",
}
// default

View File

@ -18,7 +18,6 @@ func TestGetFilterSchema(t *testing.T) {
ID uint `json:"id" gorm:"column:user_id" filter:"id,number"`
Created time.Time `json:"created" gorm:"column:user_created_date" filter:"created,date"`
Name string `json:"name" gorm:"column:user_name" filter:"name,string"`
NickName string `json:"nickname" gorm:"column:user_nickname" filter:"nickname"`
IsDisabled string `json:"is_disabled" gorm:"column:user_is_disabled" filter:"is_disabled,bool"`
Permissions string `json:"permissions" gorm:"column:user_permissions" filter:"permissions,regex"`
History string `json:"history" gorm:"column:user_history" filter:"history,regex,(id|name)"`