├── LICENSE
├── README.md
├── examples
└── simple
│ ├── .gitignore
│ ├── handlers.go
│ ├── main.go
│ ├── template.go
│ └── templates
│ ├── layout
│ ├── footer.html
│ └── header.html
│ └── pages
│ ├── home.html
│ ├── login.html
│ ├── profile.html
│ ├── signup.html
│ ├── snippets.html
│ ├── unauthorized.html
│ └── users.html
├── go.mod
├── go.sum
├── server.go
└── store.go
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2024, Manos Ragiadakos
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | 1. Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | 2. Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | 3. Neither the name of the copyright holder nor the names of its
16 | contributors may be used to endorse or promote products derived from
17 | this software without specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Badgermole
2 |
3 | Badgermole is a library for creating an SSH App server for OTP (One-Time Passwords) for authenticating requests to HTTP server.
4 | This library exists because usernames and passwords are unsafe in this day and age, while other OTP mechanics have become complicated.
5 |
6 | ## Install
7 |
8 | ```bash
9 | go get github.com/rm4n0s/badgermole
10 | ```
11 |
12 |
13 | ## How to use
14 | There is an example in 'examples/simple' which is a web server that allows the user to sign up with the SSH public key and login with the OTP from SSH.
15 | ```bash
16 | # 1) build the example
17 | cd examples/simple
18 | go build
19 |
20 | # 2) visit the home page
21 | firefox localhost:3000
22 |
23 | # 3) copy the public key
24 | cat ~/.ssh/your_public_key
25 |
26 | # 4) visit localhost:3000/signup and add a name and the key
27 |
28 | # 5) copy the OTP from SSH
29 | ssh -i ~/.ssh/your_private_key -p 10000 localhost
30 | # If you have signed up correctly then you will see something like One Time Password =
31 | # You can also get the OTP in a json format if you put "json" at the end of the command line
32 |
33 | # 6) visit localhost:3000/login and submit the One Time Password before a minute pass.
34 | # now a cookie is created that shows you are authorized
35 |
36 | ```
37 |
38 | This is just a preview
39 |
40 | ```go
41 | import "github.com/rm4n0s/badgermole"
42 |
43 | func main(){
44 | cfg := &badgermole.Config{
45 | SshHost: "localhost:3000",
46 | SshKeyPath: "sshkeys/somekey", // if it does not exist, then it will create it automatically
47 | SshAuthFunc: func(ctx ssh.Context, key ssh.PublicKey) bool {
48 | // authenticate key and return true if it is authorized to receive OTP"
49 | return true
50 | },
51 | Store: badgermole.NewMemoryStore(), // you can create your own DB for OTP as long as it implements IStore interface
52 | }
53 | bmSrv, err := badgermole.NewServer(cfg)
54 | if err != nil {
55 | log.Fatal("Error:", err)
56 | }
57 |
58 | err := bmSrv.Start()
59 | if err != nil {
60 | log.Fatal("Error:", err)
61 | }
62 | }
63 |
64 |
65 |
66 | ```
67 |
68 |
69 |
--------------------------------------------------------------------------------
/examples/simple/.gitignore:
--------------------------------------------------------------------------------
1 | .ssh
2 | simple
--------------------------------------------------------------------------------
/examples/simple/handlers.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "log"
7 | "net/http"
8 | "strings"
9 | "sync"
10 |
11 | "github.com/charmbracelet/ssh"
12 | "github.com/rm4n0s/badgermole"
13 | gossh "golang.org/x/crypto/ssh"
14 | )
15 |
16 | type Name string
17 | type SshKey string
18 |
19 | type Handlers struct {
20 | users map[SshKey]Name
21 | userMutex *sync.RWMutex
22 | tmpl *Template
23 | otpStore *badgermole.MemoryStore
24 | }
25 |
26 | func NewHandlers(otpStore *badgermole.MemoryStore) *Handlers {
27 | return &Handlers{
28 | users: map[SshKey]Name{},
29 | tmpl: NewTemplate(),
30 | otpStore: otpStore,
31 | userMutex: &sync.RWMutex{},
32 | }
33 | }
34 | func (h *Handlers) sshAuthHandler(ctx ssh.Context, key ssh.PublicKey) bool {
35 | authorizedKey := gossh.MarshalAuthorizedKey(key)
36 | cleanedKey := strings.Replace(string(authorizedKey), "\n", "", -1)
37 | h.userMutex.RLock()
38 | defer h.userMutex.RUnlock()
39 | name, ok := h.users[SshKey(cleanedKey)]
40 | if ok {
41 | log.Println(name, "passed SSH authentication")
42 | return true
43 | }
44 |
45 | return false
46 | }
47 |
48 | func (h *Handlers) getHomeHandler(w http.ResponseWriter, r *http.Request) {
49 | _, err := r.Cookie("authorized")
50 | authorized := err == nil
51 |
52 | h.tmpl.Render(w, "home.html", authorized)
53 | }
54 |
55 | func (h *Handlers) getSignUpHandler(w http.ResponseWriter, r *http.Request) {
56 | h.tmpl.Render(w, "signup.html", nil)
57 | }
58 |
59 | func (h *Handlers) postSignUpHandler(w http.ResponseWriter, r *http.Request) {
60 | name := r.FormValue("name")
61 | pk := r.FormValue("publickey")
62 | log.Println("New user signed up and the name is", name, "with public key", pk)
63 | if len(name) == 0 {
64 | h.tmpl.Render(w, "error", "name is empty")
65 | return
66 | }
67 | if len(pk) == 0 {
68 | h.tmpl.Render(w, "error", "public key is empty")
69 | return
70 | }
71 | parsed, _, _, _, err := ssh.ParseAuthorizedKey(
72 | []byte(pk),
73 | )
74 | if err != nil {
75 | h.tmpl.Render(w, "error", fmt.Sprint("public key error:", err.Error()))
76 | return
77 | }
78 | authorizedKey := gossh.MarshalAuthorizedKey(parsed)
79 | cleanedKey := strings.Replace(string(authorizedKey), "\n", "", -1)
80 | h.userMutex.Lock()
81 | h.users[SshKey(cleanedKey)] = Name(name)
82 | h.userMutex.Unlock()
83 |
84 | h.tmpl.Render(w, "signup-success", nil)
85 | }
86 |
87 | func (h *Handlers) getLoginHandler(w http.ResponseWriter, r *http.Request) {
88 | h.tmpl.Render(w, "login.html", nil)
89 | }
90 |
91 | func (h *Handlers) postLoginHandler(w http.ResponseWriter, r *http.Request) {
92 | otp := r.FormValue("otp")
93 | if len(otp) == 0 {
94 | h.tmpl.Render(w, "not-correct-otp", nil)
95 | return
96 | }
97 | au, err := h.otpStore.GetUserFromOtp(r.Context(), otp)
98 | if err != nil {
99 | h.tmpl.Render(w, "not-correct-otp", nil)
100 | return
101 | }
102 | h.userMutex.RLock()
103 | defer h.userMutex.RUnlock()
104 | name, ok := h.users[SshKey(au.SshPublicKey)]
105 | if !ok {
106 | h.tmpl.Render(w, "error", "user does not exist anymore")
107 | return
108 | }
109 | cookie := http.Cookie{
110 | Name: "authorized",
111 | Value: string(name),
112 | Path: "/",
113 | MaxAge: 3600,
114 | HttpOnly: true,
115 | Secure: true,
116 | SameSite: http.SameSiteLaxMode,
117 | }
118 |
119 | http.SetCookie(w, &cookie)
120 |
121 | h.otpStore.RemoveOtp(r.Context(), au.SshPublicKey)
122 | http.Redirect(w, r, "/", http.StatusSeeOther)
123 | }
124 |
125 | func (h *Handlers) getUsersHandler(w http.ResponseWriter, r *http.Request) {
126 | _, err := r.Cookie("authorized")
127 | if err != nil {
128 | switch {
129 | case errors.Is(err, http.ErrNoCookie):
130 | h.tmpl.Render(w, "unauthorized.html", nil)
131 | default:
132 | log.Println("Error:", err)
133 | http.Error(w, "server error", http.StatusInternalServerError)
134 | }
135 | return
136 | }
137 | h.userMutex.RLock()
138 | defer h.userMutex.RUnlock()
139 | h.tmpl.Render(w, "users.html", h.users)
140 | }
141 |
142 | func (h *Handlers) getProfileHandler(w http.ResponseWriter, r *http.Request) {
143 | cookie, err := r.Cookie("authorized")
144 | if err != nil {
145 | switch {
146 | case errors.Is(err, http.ErrNoCookie):
147 | h.tmpl.Render(w, "unauthorized.html", nil)
148 | default:
149 | log.Println("Error:", err)
150 | http.Error(w, "server error", http.StatusInternalServerError)
151 | }
152 | return
153 | }
154 | name := Name(cookie.Value)
155 | var key SshKey
156 | h.userMutex.RLock()
157 | defer h.userMutex.RUnlock()
158 | for k, v := range h.users {
159 | if v == name {
160 | key = k
161 | break
162 | }
163 | }
164 |
165 | user := struct {
166 | Name string
167 | Key string
168 | }{
169 | Name: string(name),
170 | Key: string(key),
171 | }
172 |
173 | h.tmpl.Render(w, "profile.html", user)
174 | }
175 |
176 | func (h *Handlers) getLogoutHandler(w http.ResponseWriter, r *http.Request) {
177 | cookie, err := r.Cookie("authorized")
178 | if err != nil {
179 | switch {
180 | case errors.Is(err, http.ErrNoCookie):
181 | h.tmpl.Render(w, "unauthorized.html", nil)
182 | default:
183 | log.Println("Error:", err)
184 | http.Error(w, "server error", http.StatusInternalServerError)
185 | }
186 | return
187 | }
188 | cookie.MaxAge = -1
189 | http.SetCookie(w, cookie)
190 | http.Redirect(w, r, "/", http.StatusSeeOther)
191 | }
192 |
--------------------------------------------------------------------------------
/examples/simple/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "flag"
6 | "log"
7 | "net/http"
8 | "os"
9 | "os/signal"
10 | "syscall"
11 | "time"
12 |
13 | "github.com/rm4n0s/badgermole"
14 | )
15 |
16 | func main() {
17 | log.SetFlags(log.LstdFlags | log.Lshortfile)
18 | var (
19 | webhost string
20 | sshhost string
21 | keypath string
22 | )
23 | flag.StringVar(&webhost, "webhost", "localhost:3000", "ip:port of the website")
24 | flag.StringVar(&sshhost, "sshhost", "localhost:10000", "ip:port of the ssh OTP")
25 | flag.StringVar(&keypath, "keypath", ".ssh/id_ed25519", "the path of the private key file path for the SSH OTP app")
26 | flag.Parse()
27 | otpStore := badgermole.NewMemoryStore()
28 | mux := http.NewServeMux()
29 | handlers := NewHandlers(otpStore)
30 | cfg := &badgermole.Config{
31 | SshHost: sshhost,
32 | SshKeyPath: keypath,
33 | SshAuthFunc: handlers.sshAuthHandler,
34 | Store: otpStore,
35 | }
36 | bmSrv, err := badgermole.NewServer(cfg)
37 | if err != nil {
38 | log.Fatal("Error:", err)
39 | }
40 | mux.HandleFunc("GET /", handlers.getHomeHandler)
41 | mux.HandleFunc("GET /signup", handlers.getSignUpHandler)
42 | mux.HandleFunc("POST /signup", handlers.postSignUpHandler)
43 | mux.HandleFunc("GET /login", handlers.getLoginHandler)
44 | mux.HandleFunc("POST /login", handlers.postLoginHandler)
45 | mux.HandleFunc("GET /users", handlers.getUsersHandler)
46 | mux.HandleFunc("GET /profile", handlers.getProfileHandler)
47 | mux.HandleFunc("GET /logout", handlers.getLogoutHandler)
48 |
49 | httpSrv := &http.Server{
50 | Addr: webhost,
51 | Handler: mux,
52 | }
53 | log.Println("Web server:", webhost)
54 | log.Println("OTP server:", sshhost)
55 |
56 | go func(bmSrv *badgermole.Server) {
57 | err := bmSrv.Run()
58 | if err != nil {
59 | log.Println("Error:", err)
60 | }
61 | }(bmSrv)
62 |
63 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
64 | defer stop()
65 | // clean OTP that lasted a minute
66 | go otpStore.RunCleanScheduler(ctx, 1*time.Minute)
67 |
68 | go func(ctx context.Context) {
69 | <-ctx.Done()
70 | bmSrv.Shutdown(ctx)
71 | httpSrv.Shutdown(ctx)
72 | log.Println("bye!")
73 | }(ctx)
74 |
75 | httpSrv.ListenAndServe()
76 | }
77 |
--------------------------------------------------------------------------------
/examples/simple/template.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "html/template"
5 | "io"
6 | "log"
7 | )
8 |
9 | type Template struct {
10 | tmpl *template.Template
11 | }
12 |
13 | func NewTemplate() *Template {
14 | g, err := template.ParseGlob("templates/**/*.html")
15 | if err != nil {
16 | log.Fatal(err)
17 | }
18 | return &Template{
19 | tmpl: template.Must(g, err),
20 | }
21 | }
22 |
23 | func (t *Template) Render(w io.Writer, name string, data interface{}) error {
24 | return t.tmpl.ExecuteTemplate(w, name, data)
25 | }
26 |
--------------------------------------------------------------------------------
/examples/simple/templates/layout/footer.html:
--------------------------------------------------------------------------------
1 |