package main
import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"strings"
garage "git.deuxfleurs.fr/garage-sdk/garage-admin-sdk-golang"
"github.com/go-ldap/ldap/v3"
)
var (
ErrNotAuthenticatedSession = fmt.Errorf("User has no session")
ErrNotAuthenticatedBasic = fmt.Errorf("User has not sent Authentication Basic information")
ErrNotAuthenticated = fmt.Errorf("User is not authenticated")
ErrWrongLDAPCredentials = fmt.Errorf("LDAP credentials are wrong")
ErrLDAPServerUnreachable = fmt.Errorf("Unable to open the LDAP server")
ErrLDAPSearchInternalError = fmt.Errorf("LDAP Search of this user failed with an internal error")
ErrLDAPSearchNotFound = fmt.Errorf("User is authenticated but its associated data can not be found during search")
)
// --- Login Info ---
type LoginInfo struct {
Username string
Password string
}
func NewLoginInfoFromSession(r *http.Request) (*LoginInfo, error) {
session, err := store.Get(r, SESSION_NAME)
if err == nil {
username, ok_user := session.Values["login_username"]
password, ok_pwd := session.Values["login_password"]
if ok_user && ok_pwd {
loginInfo := &LoginInfo{
Username: username.(string),
Password: password.(string),
}
return loginInfo, nil
}
}
return nil, errors.Join(ErrNotAuthenticatedSession, err)
}
func NewLoginInfoFromBasicAuth(r *http.Request) (*LoginInfo, error) {
username, password, ok := r.BasicAuth()
if ok {
login_info := &LoginInfo{
Username: username,
Password: password,
}
return login_info, nil
}
return nil, ErrNotAuthenticatedBasic
}
func (li *LoginInfo) DN() string {
user_dn := fmt.Sprintf("%s=%s,%s", config.UserNameAttr, li.Username, config.UserBaseDN)
if strings.EqualFold(li.Username, config.AdminAccount) {
user_dn = li.Username
}
return user_dn
}
// --- Login Status ---
type LoginStatus struct {
Info *LoginInfo
conn *ldap.Conn
}
func NewLoginStatus(r *http.Request, login_info *LoginInfo) (*LoginStatus, error) {
l, err := NewLdapCon()
if err != nil {
return nil, err
}
err = l.Bind(login_info.DN(), login_info.Password)
if err != nil {
return nil, errors.Join(ErrWrongLDAPCredentials, err)
}
loginStatus := &LoginStatus{
Info: login_info,
conn: l,
}
return loginStatus, nil
}
func NewLdapCon() (*ldap.Conn, error) {
l, err := ldap.DialURL(config.LdapServerAddr)
if err != nil {
return nil, errors.Join(ErrLDAPServerUnreachable, err)
}
if config.LdapTLS {
err = l.StartTLS(&tls.Config{InsecureSkipVerify: true})
if err != nil {
return nil, errors.Join(ErrLDAPServerUnreachable, err)
}
}
return l, nil
}
// --- Capabilities ---
type Capabilities struct {
CanAdmin bool
CanInvite bool
}
func NewCapabilities(login *LoginStatus, entry *ldap.Entry) *Capabilities {
// Initialize
canAdmin := false
canInvite := false
// Special case for the "admin" account that is de-facto admin
canAdmin = strings.EqualFold(login.Info.DN(), config.AdminAccount)
// Check if this account is part of a group that give capabilities
for _, attr := range entry.Attributes {
if strings.EqualFold(attr.Name, "memberof") {
for _, group := range attr.Values {
if config.GroupCanInvite != "" && strings.EqualFold(group, config.GroupCanInvite) {
canInvite = true
}
if config.GroupCanAdmin != "" && strings.EqualFold(group, config.GroupCanAdmin) {
canAdmin = true
}
}
}
}
return &Capabilities{
CanAdmin: canAdmin,
CanInvite: canInvite,
}
}
// --- Logged User ---
type LoggedUser struct {
Login *LoginStatus
Entry *ldap.Entry
Capabilities *Capabilities
Quota *UserQuota
s3key *garage.KeyInfo
}
func NewLoggedUser(login *LoginStatus) (*LoggedUser, error) {
requestKind := "(objectClass=organizationalPerson)"
if strings.EqualFold(login.Info.DN(), config.AdminAccount) {
requestKind = "(objectclass=*)"
}
searchRequest := ldap.NewSearchRequest(
login.Info.DN(),
ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false,
requestKind,
[]string{
"dn",
"displayname",
"givenname",
"sn",
"mail",
"memberof",
"description",
"garage_s3_access_key",
FIELD_NAME_DIRECTORY_VISIBILITY,
FIELD_NAME_PROFILE_PICTURE,
FIELD_QUOTA_WEBSITE_SIZE_BURSTED,
FIELD_QUOTA_WEBSITE_COUNT,
},
nil)
sr, err := login.conn.Search(searchRequest)
if err != nil {
return nil, ErrLDAPSearchInternalError
}
if len(sr.Entries) != 1 {
return nil, ErrLDAPSearchNotFound
}
entry := sr.Entries[0]
lu := &LoggedUser{
Login: login,
Entry: entry,
Capabilities: NewCapabilities(login, entry),
Quota: NewUserQuotaFromEntry(entry),
}
return lu, nil
}
func (lu *LoggedUser) WelcomeName() string {
ret := lu.Entry.GetAttributeValue("givenname")
if ret == "" {
ret = lu.Entry.GetAttributeValue("displayname")
}
if ret == "" {
ret = lu.Login.Info.Username
}
return ret
}
func (lu *LoggedUser) S3KeyInfo() (*garage.KeyInfo, error) {
var err error
var keyPair *garage.KeyInfo
if lu.s3key == nil {
keyID := lu.Entry.GetAttributeValue("garage_s3_access_key")
if keyID == "" {
// If there is no S3Key in LDAP, generate it...
keyPair, err = grgCreateKey(lu.Login.Info.Username)
if err != nil {
return nil, err
}
modify_request := ldap.NewModifyRequest(lu.Login.Info.DN(), nil)
modify_request.Replace("garage_s3_access_key", []string{*keyPair.AccessKeyId})
// @FIXME compatibility feature for bagage (SFTP+webdav)
// you can remove it once bagage will be updated to fetch the key from garage directly
// or when bottin will be able to dynamically fetch it.
modify_request.Replace("garage_s3_secret_key", []string{*keyPair.SecretAccessKey})
err = lu.Login.conn.Modify(modify_request)
if err != nil {
return nil, err
}
} else {
// There is an S3 key in LDAP, fetch its descriptor...
keyPair, err = grgGetKey(keyID)
if err != nil {
return nil, err
}
}
// Cache the keypair...
lu.s3key = keyPair
}
return lu.s3key, nil
}
// --- Require User Check
func RequireUser(r *http.Request) (*LoggedUser, error) {
var login_info *LoginInfo
if li, err := NewLoginInfoFromSession(r); err == nil {
login_info = li
} else if li, err := NewLoginInfoFromBasicAuth(r); err == nil {
login_info = li
} else {
return nil, ErrNotAuthenticated
}
loginStatus, err := NewLoginStatus(r, login_info)
if err != nil {
return nil, err
}
return NewLoggedUser(loginStatus)
}
func RequireUserHtml(w http.ResponseWriter, r *http.Request) *LoggedUser {
user, err := RequireUser(r)
if errors.Is(err, ErrNotAuthenticated) || errors.Is(err, ErrWrongLDAPCredentials) {
http.Redirect(w, r, "/login", http.StatusFound)
return nil
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil
}
return user
}
func RequireUserApi(w http.ResponseWriter, r *http.Request) *LoggedUser {
user, err := RequireUser(r)
if errors.Is(err, ErrNotAuthenticated) || errors.Is(err, ErrWrongLDAPCredentials) {
http.Error(w, err.Error(), http.StatusUnauthorized)
return nil
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil
}
return user
}