diff options
author | Alex Auvolat <alex@adnab.me> | 2020-02-14 21:58:34 +0100 |
---|---|---|
committer | Alex Auvolat <alex@adnab.me> | 2020-02-14 21:58:34 +0100 |
commit | 151a31a4250cde38d8a66f2bfc8390f8132dc552 (patch) | |
tree | 5a5b775b4657e5d0ab44ac6f84e10b55d984bba2 | |
parent | 768f2de9162bbf3fd0a1005554f3fd595818f1b3 (diff) | |
download | guichet-151a31a4250cde38d8a66f2bfc8390f8132dc552.tar.gz guichet-151a31a4250cde38d8a66f2bfc8390f8132dc552.zip |
Invitation mechanism with codes etc
-rw-r--r-- | admin.go | 4 | ||||
-rw-r--r-- | go.mod | 2 | ||||
-rw-r--r-- | go.sum | 5 | ||||
-rw-r--r-- | invite.go | 194 | ||||
-rw-r--r-- | main.go | 66 | ||||
-rw-r--r-- | templates/home.html | 2 | ||||
-rw-r--r-- | templates/invite_invalid_code.html | 12 | ||||
-rw-r--r-- | templates/invite_mail.txt | 13 | ||||
-rw-r--r-- | templates/invite_send_code.html | 62 |
9 files changed, 328 insertions, 32 deletions
@@ -600,9 +600,9 @@ func handleAdminCreate(w http.ResponseWriter, r *http.Request) { } else { dn := data.IdType + "=" + data.IdValue + "," + super_dn req := ldap.NewAddRequest(dn, nil) - req.Attribute("objectClass", object_class) + req.Attribute("objectclass", object_class) if data.StructuralObjectClass != "" { - req.Attribute("structuralObjectClass", []string{data.StructuralObjectClass}) + req.Attribute("structuralobjectclass", []string{data.StructuralObjectClass}) } if data.DisplayName != "" { req.Attribute("displayname", []string{data.DisplayName}) @@ -3,6 +3,8 @@ module deuxfleurs.fr/Deuxfleurs/guichet go 1.13 require ( + github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b + github.com/emersion/go-smtp v0.12.1 github.com/go-ldap/ldap v3.0.3+incompatible github.com/go-ldap/ldap/v3 v3.1.6 github.com/gorilla/mux v1.7.3 @@ -1,4 +1,9 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-sasl v0.0.0-20190817083125-240c8404624e/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= +github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b h1:uhWtEWBHgop1rqEk2klKaxPAkVDCXexai6hSuRQ7Nvs= +github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= +github.com/emersion/go-smtp v0.12.1 h1:1R8BDqrR2HhlGwgFYcOi+BVTvK1bMjAB65QcVpJ5sNA= +github.com/emersion/go-smtp v0.12.1/go.mod h1:SD9V/xa4ndMw77lR3Mf7htkp8RBNYuPh9UeuBs9tpUQ= github.com/go-asn1-ber/asn1-ber v1.3.1 h1:gvPdv/Hr++TRFCl0UbPFHC54P9N9jgsRPnmnr419Uck= github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-ldap/ldap v3.0.3+incompatible h1:HTeSZO8hWMS1Rgb2Ziku6b8a7qRIZZMHjsvuZyatzwk= @@ -1,15 +1,26 @@ package main import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" "fmt" "html/template" + "log" "net/http" "regexp" "strings" + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" "github.com/go-ldap/ldap/v3" + "github.com/gorilla/mux" ) +var EMAIL_REGEXP = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + func checkInviterLogin(w http.ResponseWriter, r *http.Request) *LoginStatus { login := checkLogin(w, r) if login == nil { @@ -24,6 +35,47 @@ func checkInviterLogin(w http.ResponseWriter, r *http.Request) *LoginStatus { return login } +// New account creation directly from interface + +func handleInviteNewAccount(w http.ResponseWriter, r *http.Request) { + login := checkInviterLogin(w, r) + if login == nil { + return + } + + handleNewAccount(w, r, login.conn) +} + +// New account creation using code + +func handleInvitationCode(w http.ResponseWriter, r *http.Request) { + code := mux.Vars(r)["code"] + code_id, code_pw := readCode(code) + + l := ldapOpen(w) + if l == nil { + return + } + + inviteDn := config.InvitationNameAttr + "=" + code_id + "," + config.InvitationBaseDN + err := l.Bind(inviteDn, code_pw) + if err != nil { + templateInviteInvalidCode := template.Must(template.ParseFiles("templates/layout.html", "templates/invite_invalid_code.html")) + templateInviteInvalidCode.Execute(w, nil) + return + } + + if handleNewAccount(w, r, l) { + del_req := ldap.NewDelRequest(inviteDn, nil) + err = l.Del(del_req) + if err != nil { + log.Printf("Could not delete invitation %s: %s", inviteDn, err) + } + } +} + +// Common functions for new account + type NewAccountData struct { Username string DisplayName string @@ -39,14 +91,9 @@ type NewAccountData struct { Success bool } -func handleInviteNewAccount(w http.ResponseWriter, r *http.Request) { +func handleNewAccount(w http.ResponseWriter, r *http.Request, l *ldap.Conn) bool { templateInviteNewAccount := template.Must(template.ParseFiles("templates/layout.html", "templates/invite_new_account.html")) - login := checkInviterLogin(w, r) - if login == nil { - return - } - data := &NewAccountData{} if r.Method == "POST" { @@ -60,10 +107,11 @@ func handleInviteNewAccount(w http.ResponseWriter, r *http.Request) { password1 := strings.Join(r.Form["password"], "") password2 := strings.Join(r.Form["password2"], "") - tryCreateAccount(login.conn, data, password1, password2) + tryCreateAccount(l, data, password1, password2) } templateInviteNewAccount.Execute(w, data) + return data.Success } func tryCreateAccount(l *ldap.Conn, data *NewAccountData, pass1 string, pass2 string) { @@ -140,5 +188,137 @@ func tryCreateAccount(l *ldap.Conn, data *NewAccountData, pass1 string, pass2 st data.Success = true } +// ---- Code generation ---- + +type SendCodeData struct { + ErrorMessage string + ErrorInvalidEmail bool + Success bool + CodeDisplay string + CodeSentTo string + WebBaseAddress string +} + +type CodeMailFields struct { + From string + To string + Code string + InviteFrom string + WebBaseAddress string +} + func handleInviteSendCode(w http.ResponseWriter, r *http.Request) { + templateInviteSendCode := template.Must(template.ParseFiles("templates/layout.html", "templates/invite_send_code.html")) + + login := checkInviterLogin(w, r) + if login == nil { + return + } + + data := &SendCodeData{ + WebBaseAddress: config.WebAddress, + } + + if r.Method == "POST" { + r.ParseForm() + + choice := strings.Join(r.Form["choice"], "") + if choice != "display" && choice != "send" { + http.Error(w, "Invalid entry", http.StatusBadRequest) + return + } + sendto := strings.Join(r.Form["sendto"], "") + + trySendCode(login, choice, sendto, data) + } + + templateInviteSendCode.Execute(w, data) +} + +func trySendCode(login *LoginStatus, choice string, sendto string, data *SendCodeData) { + // Generate code + code, code_id, code_pw := genCode() + + // Create invitation object in database + inviteDn := config.InvitationNameAttr + "=" + code_id + "," + config.InvitationBaseDN + req := ldap.NewAddRequest(inviteDn, nil) + req.Attribute("userpassword", []string{SSHAEncode([]byte(code_pw))}) + req.Attribute("objectclass", []string{"top", "invitationCode"}) + + err := login.conn.Add(req) + if err != nil { + data.ErrorMessage = err.Error() + return + } + + // If we want to display it, do so + if choice == "display" { + data.Success = true + data.CodeDisplay = code + return + } + + // Otherwise, we are sending a mail + if !EMAIL_REGEXP.MatchString(sendto) { + data.ErrorInvalidEmail = true + return + } + + templateMail := template.Must(template.ParseFiles("templates/invite_mail.txt")) + buf := bytes.NewBuffer([]byte{}) + templateMail.Execute(buf, &CodeMailFields{ + To: sendto, + From: config.MailFrom, + InviteFrom: login.WelcomeName(), + Code: code, + WebBaseAddress: config.WebAddress, + }) + + log.Printf("Sending mail to: %s", sendto) + var auth sasl.Client = nil + if config.SMTPUsername != "" { + auth = sasl.NewPlainClient("", config.SMTPUsername, config.SMTPPassword) + } + err = smtp.SendMail(config.SMTPServer, auth, config.MailFrom, []string{sendto}, buf) + if err != nil { + data.ErrorMessage = err.Error() + return + } + log.Printf("Mail sent.") + + data.Success = true + data.CodeSentTo = sendto +} + +func genCode() (code string, code_id string, code_pw string) { + random := make([]byte, 32) + n, err := rand.Read(random) + if err != nil || n != 32 { + log.Fatalf("Could not generate random bytes: %s", err) + } + + a := binary.BigEndian.Uint32(random[0:4]) + b := binary.BigEndian.Uint32(random[4:8]) + c := binary.BigEndian.Uint32(random[8:12]) + + code = fmt.Sprintf("%03d-%03d-%03d", a%1000, b%1000, c%1000) + code_id, code_pw = readCode(code) + return +} + +func readCode(code string) (code_id string, code_pw string) { + // Strip everything that is not a digit + code_digits := "" + for _, c := range code { + if c >= '0' && c <= '9' { + code_digits = code_digits + string(c) + } + } + + id_hash := sha256.Sum256([]byte("Guichet ID " + code_digits)) + pw_hash := sha256.Sum256([]byte("Guichet PW " + code_digits)) + + code_id = hex.EncodeToString(id_hash[:8]) + code_pw = hex.EncodeToString(pw_hash[:16]) + return } @@ -23,16 +23,23 @@ type ConfigFile struct { LdapServerAddr string `json:"ldap_server_addr"` LdapTLS bool `json:"ldap_tls"` - BaseDN string `json:"base_dn"` - UserBaseDN string `json:"user_base_dn"` - UserNameAttr string `json:"user_name_attr"` - GroupBaseDN string `json:"group_base_dn"` - GroupNameAttr string `json:"group_name_attr"` + BaseDN string `json:"base_dn"` + UserBaseDN string `json:"user_base_dn"` + UserNameAttr string `json:"user_name_attr"` + GroupBaseDN string `json:"group_base_dn"` + GroupNameAttr string `json:"group_name_attr"` + InvitationBaseDN string `json:"invitation_base_dn"` InvitationNameAttr string `json:"invitation_name_attr"` InvitedMailFormat string `json:"invited_mail_format"` InvitedAutoGroups []string `json:"invited_auto_groups"` + WebAddress string `json:"web_address"` + MailFrom string `json:"mail_from"` + SMTPServer string `json:"smtp_server"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password"` + AdminAccount string `json:"admin_account"` GroupCanInvite string `json:"group_can_invite"` GroupCanAdmin string `json:"group_can_admin"` @@ -51,11 +58,22 @@ func readConfig() ConfigFile { HttpBindAddr: ":9991", LdapServerAddr: "ldap://127.0.0.1:389", LdapTLS: false, - BaseDN: "dc=example,dc=com", - UserBaseDN: "ou=users,dc=example,dc=com", - UserNameAttr: "uid", - GroupBaseDN: "ou=groups,dc=example,dc=com", - GroupNameAttr: "gid", + + BaseDN: "dc=example,dc=com", + UserBaseDN: "ou=users,dc=example,dc=com", + UserNameAttr: "uid", + GroupBaseDN: "ou=groups,dc=example,dc=com", + GroupNameAttr: "gid", + + InvitationBaseDN: "ou=invitations,dc=example,dc=com", + InvitationNameAttr: "cn", + InvitedMailFormat: "{}@example.com", + InvitedAutoGroups: []string{}, + + WebAddress: "https://guichet.example.com", + MailFrom: "guichet@example.com", + SMTPServer: "smtp.example.com", + AdminAccount: "uid=admin,dc=example,dc=com", GroupCanInvite: "", GroupCanAdmin: "gid=admin,ou=groups,dc=example,dc=com", @@ -117,6 +135,7 @@ func main() { r.HandleFunc("/invite/new_account", handleInviteNewAccount) r.HandleFunc("/invite/send_code", handleInviteSendCode) + r.HandleFunc("/invitation/{code}", handleInvitationCode) r.HandleFunc("/admin/users", handleAdminUsers) r.HandleFunc("/admin/groups", handleAdminGroups) @@ -147,6 +166,17 @@ type LoginStatus struct { CanInvite bool } +func (login *LoginStatus) WelcomeName() string { + ret := login.UserEntry.GetAttributeValue("givenname") + if ret == "" { + ret = login.UserEntry.GetAttributeValue("displayname") + } + if ret == "" { + ret = login.Info.Username + } + return ret +} + func logRequest(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Printf("%s %s %s\n", r.RemoteAddr, r.Method, r.URL) @@ -266,9 +296,8 @@ func ldapOpen(w http.ResponseWriter) *ldap.Conn { // Page handlers ---- type HomePageData struct { - Login *LoginStatus - WelcomeName string - BaseDN string + Login *LoginStatus + BaseDN string } func handleHome(w http.ResponseWriter, r *http.Request) { @@ -280,15 +309,8 @@ func handleHome(w http.ResponseWriter, r *http.Request) { } data := &HomePageData{ - Login: login, - BaseDN: config.BaseDN, - WelcomeName: login.UserEntry.GetAttributeValue("givenname"), - } - if data.WelcomeName == "" { - data.WelcomeName = login.UserEntry.GetAttributeValue("displayname") - } - if data.WelcomeName == "" { - data.WelcomeName = login.Info.Username + Login: login, + BaseDN: config.BaseDN, } templateHome.Execute(w, data) diff --git a/templates/home.html b/templates/home.html index 57d6930..5556ba7 100644 --- a/templates/home.html +++ b/templates/home.html @@ -2,7 +2,7 @@ {{define "body"}} <div class="alert alert-info"> - Bienvenue, <strong>{{ .WelcomeName }}</strong> ! + Bienvenue, <strong>{{ .Login.WelcomeName }}</strong> ! </div> <div class="d-flex"> <a class="ml-auto btn btn-sm btn-dark" href="/logout">Se déconnecter</a> diff --git a/templates/invite_invalid_code.html b/templates/invite_invalid_code.html new file mode 100644 index 0000000..107afab --- /dev/null +++ b/templates/invite_invalid_code.html @@ -0,0 +1,12 @@ +{{define "title"}}Créer un compte |{{end}} + +{{define "body"}} +<div class="d-flex"> + <h4>Création d'un nouveau compte</h4> + <a class="ml-auto btn btn-info" href="/">Retour</a> +</div> + + <div class="alert alert-danger mt-4"> + Code d'invitation invalide. + </div> +{{end}} diff --git a/templates/invite_mail.txt b/templates/invite_mail.txt new file mode 100644 index 0000000..f1667b7 --- /dev/null +++ b/templates/invite_mail.txt @@ -0,0 +1,13 @@ +From: {{.From}} +To: {{.To}} +Subject: Code d'invitation Deuxfleurs +Content-type: text/plain; charset=utf-8 + +Vous avez été invité à créer un compte sur Deuxfleurs par {{.InviteFrom}} :) + +Pour créer votre compte, rendez-vous à l'addresse suivante: + +{{.WebBaseAddress}}/invite/{{.Code}} + +À bientôt sur Deuxfleurs ! + diff --git a/templates/invite_send_code.html b/templates/invite_send_code.html new file mode 100644 index 0000000..8d09b3a --- /dev/null +++ b/templates/invite_send_code.html @@ -0,0 +1,62 @@ +{{define "title"}}Envoyer un code d'invitation |{{end}} + +{{define "body"}} +<div class="d-flex"> + <h4>Envoyer un code d'invitation</h4> + <a class="ml-auto btn btn-info" href="/">Retour</a> +</div> + + {{if .ErrorMessage}} + <div class="alert alert-danger mt-4">Impossible de génerer ou d'envoyer le code. + <div style="font-size: 0.8em">{{ .ErrorMessage }}</div> + </div> + {{end}} + {{if .Success}} + <div class="alert alert-success mt-4"> + {{if .CodeSentTo}} + Un code d'invitation a bien été envoyé à <code>{{ .CodeSentTo }}</code>. + {{end}} + {{if .CodeDisplay}} + Le code généré est le suivant: + + <p style="text-align: center; font-size: 1.6em;" class="mt-4 mb-4"> + {{ .CodeDisplay }} + </p> + <p style="text-align: center" class="mt-4 mb-4"> + <a href="{{.WebBaseAddress}}/invitation/{{ .CodeDisplay }}">{{.WebBaseAddress}}/invitation/{{.CodeDisplay}}</a> + </p> + {{end}} + </div> + {{else}} + <form method="POST" class="mt-4"> + Choisissez une option: + + <div class="input-group mt-4"> + <div class="input-group-prepend"> + <div class="input-group-text"> + <input type="radio" name="choice" value="display" id="choice_display" checked="true"> + </div> + </div> + <label class="form-control" for="choice_display"> + Afficher le code et me laisser l'envoyer + </label> + </div> + + <div class="input-group mt-4"> + <div class="input-group-prepend"> + <div class="input-group-text"> + <input type="radio" name="choice" value="send" id="choice_send" aria-label="Checkbox for following text input"> + </div> + </div> + <label class="form-control" for="choice_send"> + Envoyer le code à l'addresse suivante: + </label> + <input class="form-control" type="text" name="sendto" id="sendto" placeholder="Addresse mail..." onclick="document.getElementById('choice_send').checked = true;" /> + </div> + + <div class="form-group mt-4"> + <button type="submit" class="btn btn-primary">Génerer le code</button> + </div> + </form> + {{end}} +{{end}} |