221 lines
6.1 KiB
Go
221 lines
6.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/alexedwards/argon2id"
|
|
"github.com/eternal-flame-AD/yoake/config"
|
|
"github.com/gorilla/sessions"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/labstack/echo/v4/middleware"
|
|
)
|
|
|
|
const AuthSessionName = "auth_session"
|
|
|
|
type RequestAuth struct {
|
|
Present bool
|
|
Valid bool
|
|
Roles []string
|
|
Expire time.Time
|
|
}
|
|
|
|
func (a RequestAuth) HasRole(role Role) bool {
|
|
if !a.Valid {
|
|
return false
|
|
}
|
|
for _, r := range a.Roles {
|
|
if r == string(role) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type RoleInsufficientError struct {
|
|
RoleRequired Role
|
|
RolesAvailable []string
|
|
}
|
|
|
|
func (e RoleInsufficientError) Error() string {
|
|
return fmt.Sprintf("role insufficient: required %v, you have %v", e.RoleRequired, e.RolesAvailable)
|
|
}
|
|
|
|
func (e RoleInsufficientError) Code() int {
|
|
if len(e.RolesAvailable) == 0 {
|
|
return http.StatusUnauthorized
|
|
}
|
|
return http.StatusForbidden
|
|
}
|
|
|
|
func (a RequestAuth) RequireRole(role Role) error {
|
|
if config := config.Config(); config.Auth.DevMode.GrantAll && !config.Listen.Ssl.Use {
|
|
log.Printf("dev mode: role %v granted without checking", role)
|
|
return nil
|
|
}
|
|
if a.HasRole(role) {
|
|
return nil
|
|
}
|
|
return RoleInsufficientError{RoleRequired: role, RolesAvailable: a.Roles}
|
|
}
|
|
|
|
func RequireMiddleware(role Role) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
auth := GetRequestAuth(c)
|
|
if err := auth.RequireRole(role); err != nil {
|
|
return err
|
|
}
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func Middleware(store sessions.Store) echo.MiddlewareFunc {
|
|
yubiAuthLazyInit()
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
|
|
sess, _ := store.Get(c.Request(), AuthSessionName)
|
|
sess.Options = &sessions.Options{
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
}
|
|
|
|
var auth RequestAuth
|
|
if expireTs, ok := sess.Values["expire"].(string); ok {
|
|
auth.Present = true
|
|
if expireTime, err := time.Parse(time.RFC3339, expireTs); err != nil {
|
|
log.Printf("invalid expireTime: %v", expireTs)
|
|
} else {
|
|
auth.Expire = expireTime
|
|
if time.Now().Before(expireTime) {
|
|
auth.Valid = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if existingRoles, ok := sess.Values["roles"].([]string); !ok {
|
|
sess.Values["roles"] = []string{}
|
|
sess.Save(c.Request(), c.Response())
|
|
} else if auth.Valid {
|
|
auth.Roles = existingRoles
|
|
}
|
|
|
|
c.Set("auth_"+AuthSessionName, auth)
|
|
c.Set("auth_store", store)
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func issueSession(c echo.Context, period time.Duration, roles []string) error {
|
|
sess, _ := c.Get("auth_store").(sessions.Store).Get(c.Request(), AuthSessionName)
|
|
sess.Options = &sessions.Options{
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
}
|
|
if period == 0 {
|
|
period = time.Duration(config.Config().Auth.ValidMinutes) * time.Minute
|
|
}
|
|
if period < 0 {
|
|
sess.Values["expire"] = (time.Time{}).Format(time.RFC3339)
|
|
sess.Values["roles"] = ""
|
|
} else {
|
|
sess.Values["expire"] = time.Now().Add(period).Format(time.RFC3339)
|
|
sess.Values["roles"] = roles
|
|
log.Printf("Issued session for %v, roles: %v", period, roles)
|
|
}
|
|
return sess.Save(c.Request(), c.Response())
|
|
}
|
|
|
|
type LoginForm struct {
|
|
Username string `json:"username" form:"username"`
|
|
Password string `json:"password" form:"password"`
|
|
OtpResponse string `json:"otp_response" form:"otp_response"`
|
|
}
|
|
|
|
func Register(g *echo.Group) (err error) {
|
|
g.GET("/auth.json", func(c echo.Context) error {
|
|
return c.JSON(http.StatusOK, GetRequestAuth(c))
|
|
})
|
|
|
|
loginRateLimiterStore := middleware.NewRateLimiterMemoryStoreWithConfig(middleware.RateLimiterMemoryStoreConfig{
|
|
ExpiresIn: 300 * time.Second,
|
|
Rate: 2,
|
|
Burst: 4,
|
|
})
|
|
loginRateLimiter := middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
|
|
Store: loginRateLimiterStore,
|
|
})
|
|
g.POST("/login", func(c echo.Context) error {
|
|
var form LoginForm
|
|
if err := c.Bind(&form); err != nil {
|
|
return err
|
|
}
|
|
var verifiedOtpPubId string
|
|
if form.OtpResponse != "" {
|
|
if yubiAuth == nil {
|
|
return echo.NewHTTPError(http.StatusNotImplemented, "Yubikey authentication not configured")
|
|
}
|
|
if yr, ok, err := yubiAuth.Verify(form.OtpResponse); err != nil {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "Yubikey authentication failed: "+err.Error())
|
|
} else if !ok {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "Yubikey authentication failed")
|
|
} else {
|
|
// sessionUseCounter := yr.GetResultParameter("sessionuse")
|
|
// sessionCounter := yr.GetResultParameter("sessioncounter")
|
|
keyPublicId := yr.GetResultParameter("otp")[:12]
|
|
verifiedOtpPubId = keyPublicId
|
|
}
|
|
}
|
|
|
|
if form.Username == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "username required")
|
|
}
|
|
if form.Password == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "password required")
|
|
}
|
|
if user, ok := config.Config().Auth.Users[form.Username]; ok {
|
|
if len(user.PublicKeyId) > 0 {
|
|
if verifiedOtpPubId == "" {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "otp required")
|
|
}
|
|
found := 0
|
|
for _, pubId := range user.PublicKeyId {
|
|
found += subtle.ConstantTimeCompare([]byte(pubId[:12]), []byte(verifiedOtpPubId))
|
|
}
|
|
if found == 0 {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "incorrect key used")
|
|
}
|
|
} else if verifiedOtpPubId != "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "otp not required but you provided one, this may be an configuration error")
|
|
}
|
|
|
|
if match, _ := argon2id.ComparePasswordAndHash(form.Password, user.Password); match {
|
|
issueSession(c, 0, user.Roles)
|
|
c.JSON(http.StatusOK, map[string]interface{}{"message": "ok", "ok": true})
|
|
return nil
|
|
} else {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "incorrect password")
|
|
}
|
|
}
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid username")
|
|
}, loginRateLimiter)
|
|
g.DELETE("/login", func(c echo.Context) error {
|
|
return issueSession(c, -1, nil)
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func GetRequestAuth(c echo.Context) RequestAuth {
|
|
if a, ok := c.Get("auth_" + AuthSessionName).(RequestAuth); ok {
|
|
return a
|
|
} else {
|
|
return RequestAuth{Present: false, Valid: false}
|
|
}
|
|
}
|