Skip to content

Commit 51ad0b7

Browse files
brechtvlbartvdbraak
authored andcommitted
BLENDER: Blender ID goth provider
Provider authored by Matti Ranta and Arnd Marijnissen.
1 parent 7ed1e89 commit 51ad0b7

File tree

10 files changed

+491
-0
lines changed

10 files changed

+491
-0
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ require (
257257
github.com/mitchellh/reflectwalk v1.0.2 // indirect
258258
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
259259
github.com/modern-go/reflect2 v1.0.2 // indirect
260+
github.com/mozillazg/go-unidecode v0.2.0 // indirect
260261
github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 // indirect
261262
github.com/mschoch/smat v0.2.0 // indirect
262263
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect

go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,9 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
590590
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
591591
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
592592
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
593+
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
594+
github.com/mozillazg/go-unidecode v0.2.0 h1:vFGEzAH9KSwyWmXCOblazEWDh7fOkpmy/Z4ArmamSUc=
595+
github.com/mozillazg/go-unidecode v0.2.0/go.mod h1:zB48+/Z5toiRolOZy9ksLryJ976VIwmDmpQ2quyt1aA=
593596
github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 h1:j2kD3MT1z4PXCiUllUJF9mWUESr9TWKS7iEKsQ/IipM=
594597
github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM=
595598
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=

public/assets/img/blenderid.png

Loading
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
// Package blenderid implements the OAuth2 protocol for authenticating users through Blender ID
4+
// This package can be used as a reference implementation of an OAuth2 provider for Goth.
5+
package blenderid
6+
7+
// Allow "encoding/json" import.
8+
import (
9+
"bytes"
10+
"encoding/json" //nolint:depguard
11+
"errors"
12+
"fmt"
13+
"io"
14+
"net/http"
15+
"strconv"
16+
17+
"github.com/markbates/goth"
18+
"golang.org/x/oauth2"
19+
)
20+
21+
// These vars define the default Authentication, Token, and Profile URLS for Blender ID
22+
//
23+
// Examples:
24+
//
25+
// blenderid.AuthURL = "https://id.blender.org/oauth/authorize
26+
// blenderid.TokenURL = "https://id.blender.org/oauth/token
27+
// blenderid.ProfileURL = "https://id.blender.org/api/me
28+
var (
29+
AuthURL = "https://id.blender.org/oauth/authorize"
30+
TokenURL = "https://id.blender.org/oauth/token"
31+
ProfileURL = "https://id.blender.org/api/me"
32+
)
33+
34+
// Provider is the implementation of `goth.Provider` for accessing Blender ID
35+
type Provider struct {
36+
ClientKey string
37+
Secret string
38+
CallbackURL string
39+
HTTPClient *http.Client
40+
config *oauth2.Config
41+
providerName string
42+
profileURL string
43+
}
44+
45+
// New creates a new Blender ID provider and sets up important connection details.
46+
// You should always call `blenderid.New` to get a new provider. Never try to
47+
// create one manually.
48+
func New(clientKey, secret, callbackURL string, scopes ...string) *Provider {
49+
return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, scopes...)
50+
}
51+
52+
// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to
53+
func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider {
54+
p := &Provider{
55+
ClientKey: clientKey,
56+
Secret: secret,
57+
CallbackURL: callbackURL,
58+
providerName: "blenderid",
59+
profileURL: profileURL,
60+
}
61+
p.config = newConfig(p, authURL, tokenURL, scopes)
62+
return p
63+
}
64+
65+
// Name is the name used to retrieve this provider later.
66+
func (p *Provider) Name() string {
67+
return p.providerName
68+
}
69+
70+
// SetName is to update the name of the provider (needed in case of multiple providers of 1 type)
71+
func (p *Provider) SetName(name string) {
72+
p.providerName = name
73+
}
74+
75+
func (p *Provider) Client() *http.Client {
76+
return goth.HTTPClientWithFallBack(p.HTTPClient)
77+
}
78+
79+
// Debug is a no-op for the blenderid package.
80+
func (p *Provider) Debug(debug bool) {}
81+
82+
// BeginAuth asks Blender ID for an authentication end-point.
83+
func (p *Provider) BeginAuth(state string) (goth.Session, error) {
84+
return &Session{
85+
AuthURL: p.config.AuthCodeURL(state),
86+
}, nil
87+
}
88+
89+
// FetchUser will go to Blender ID and access basic information about the user.
90+
func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
91+
sess := session.(*Session)
92+
user := goth.User{
93+
AccessToken: sess.AccessToken,
94+
Provider: p.Name(),
95+
RefreshToken: sess.RefreshToken,
96+
ExpiresAt: sess.ExpiresAt,
97+
}
98+
99+
if user.AccessToken == "" {
100+
// data is not yet retrieved since accessToken is still empty
101+
return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName)
102+
}
103+
104+
req, err := http.NewRequest("GET", p.profileURL, nil)
105+
if err != nil {
106+
return user, err
107+
}
108+
109+
req.Header.Add("Authorization", "Bearer "+sess.AccessToken)
110+
response, err := p.Client().Do(req)
111+
if err != nil {
112+
return user, err
113+
}
114+
if response.StatusCode != http.StatusOK {
115+
return user, fmt.Errorf("Blender ID responded with a %d trying to fetch user information", response.StatusCode)
116+
}
117+
118+
bits, err := io.ReadAll(response.Body)
119+
if err != nil {
120+
return user, err
121+
}
122+
123+
err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData)
124+
if err != nil {
125+
return user, err
126+
}
127+
128+
err = userFromReader(bytes.NewReader(bits), &user)
129+
if err != nil {
130+
return user, err
131+
}
132+
133+
return user, err
134+
}
135+
136+
func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config {
137+
c := &oauth2.Config{
138+
ClientID: provider.ClientKey,
139+
ClientSecret: provider.Secret,
140+
RedirectURL: provider.CallbackURL,
141+
Endpoint: oauth2.Endpoint{
142+
AuthURL: authURL,
143+
TokenURL: tokenURL,
144+
},
145+
Scopes: []string{},
146+
}
147+
148+
if len(scopes) > 0 {
149+
c.Scopes = append(c.Scopes, scopes...)
150+
}
151+
return c
152+
}
153+
154+
func userFromReader(r io.Reader, user *goth.User) error {
155+
u := struct {
156+
Name string `json:"full_name"`
157+
Email string `json:"email"`
158+
NickName string `json:"nickname"`
159+
ID int `json:"id"`
160+
}{}
161+
err := json.NewDecoder(r).Decode(&u)
162+
if err != nil {
163+
return err
164+
}
165+
user.Email = u.Email
166+
user.Name = u.Name
167+
user.NickName = gitealizeUsername(u.NickName)
168+
user.UserID = strconv.Itoa(u.ID)
169+
user.AvatarURL = fmt.Sprintf("https://id.blender.org/api/user/%s/avatar", user.UserID)
170+
return nil
171+
}
172+
173+
// RefreshTokenAvailable refresh token is not provided by Blender ID
174+
func (p *Provider) RefreshTokenAvailable() bool {
175+
return true
176+
}
177+
178+
// RefreshToken refresh token is not provided by Blender ID
179+
func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
180+
return nil, errors.New("Refresh token is not provided by Blender ID")
181+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
package blenderid_test
4+
5+
import (
6+
"os"
7+
"testing"
8+
9+
"code.gitea.io/gitea/services/auth/source/oauth2/blenderid"
10+
11+
"github.com/markbates/goth"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func Test_New(t *testing.T) {
16+
t.Parallel()
17+
a := assert.New(t)
18+
p := provider()
19+
20+
a.Equal(p.ClientKey, os.Getenv("BLENDERID_KEY"))
21+
a.Equal(p.Secret, os.Getenv("BLENDERID_SECRET"))
22+
a.Equal("/foo", p.CallbackURL)
23+
}
24+
25+
func Test_NewCustomisedURL(t *testing.T) {
26+
t.Parallel()
27+
a := assert.New(t)
28+
p := urlCustomisedURLProvider()
29+
session, err := p.BeginAuth("test_state")
30+
s := session.(*blenderid.Session)
31+
a.NoError(err)
32+
a.Contains(s.AuthURL, "http://authURL")
33+
}
34+
35+
func Test_Implements_Provider(t *testing.T) {
36+
t.Parallel()
37+
a := assert.New(t)
38+
a.Implements((*goth.Provider)(nil), provider())
39+
}
40+
41+
func Test_BeginAuth(t *testing.T) {
42+
t.Parallel()
43+
a := assert.New(t)
44+
p := provider()
45+
session, err := p.BeginAuth("test_state")
46+
s := session.(*blenderid.Session)
47+
a.NoError(err)
48+
a.Contains(s.AuthURL, "id.blender.org/oauth/authorize")
49+
}
50+
51+
func Test_SessionFromJSON(t *testing.T) {
52+
t.Parallel()
53+
a := assert.New(t)
54+
55+
p := provider()
56+
session, err := p.UnmarshalSession(`{"AuthURL":"https://id.blender.org/oauth/authorize","AccessToken":"1234567890"}`)
57+
a.NoError(err)
58+
59+
s := session.(*blenderid.Session)
60+
a.Equal("https://id.blender.org/oauth/authorize", s.AuthURL)
61+
a.Equal("1234567890", s.AccessToken)
62+
}
63+
64+
func provider() *blenderid.Provider {
65+
return blenderid.New(os.Getenv("BLENDERID_KEY"), os.Getenv("BLENDERID_SECRET"), "/foo")
66+
}
67+
68+
func urlCustomisedURLProvider() *blenderid.Provider {
69+
return blenderid.NewCustomisedURL(os.Getenv("BLENDERID_KEY"), os.Getenv("BLENDERID_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL")
70+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
package blenderid
4+
5+
import (
6+
"regexp"
7+
"strings"
8+
9+
"code.gitea.io/gitea/models/user"
10+
11+
"github.com/mozillazg/go-unidecode"
12+
)
13+
14+
var (
15+
reInvalidCharsPattern = regexp.MustCompile(`[^\da-zA-Z.\w-]+`)
16+
17+
// Consecutive non-alphanumeric at start:
18+
reConsPrefix = regexp.MustCompile(`^[._-]+`)
19+
reConsSuffix = regexp.MustCompile(`[._-]+$`)
20+
reConsInfix = regexp.MustCompile(`[._-]{2,}`)
21+
)
22+
23+
// gitealizeUsername turns a valid Blender ID nickname into a valid Gitea username.
24+
func gitealizeUsername(bidNickname string) string {
25+
// Remove accents and other non-ASCIIness.
26+
asciiUsername := unidecode.Unidecode(bidNickname)
27+
asciiUsername = strings.TrimSpace(asciiUsername)
28+
asciiUsername = strings.ReplaceAll(asciiUsername, " ", "_")
29+
30+
err := user.IsUsableUsername(asciiUsername)
31+
if err == nil && len(asciiUsername) <= 40 {
32+
return asciiUsername
33+
}
34+
35+
newUsername := asciiUsername
36+
newUsername = reInvalidCharsPattern.ReplaceAllString(newUsername, "_")
37+
newUsername = reConsPrefix.ReplaceAllString(newUsername, "")
38+
newUsername = reConsSuffix.ReplaceAllString(newUsername, "")
39+
newUsername = reConsInfix.ReplaceAllStringFunc(
40+
newUsername,
41+
func(match string) string {
42+
firstRune := []rune(match)[0]
43+
return string(firstRune)
44+
})
45+
46+
if newUsername == "" {
47+
// Everything was stripped and nothing was left. Better to keep as-is and
48+
// just let Gitea bork on it.
49+
return asciiUsername
50+
}
51+
52+
// This includes a test for reserved names, which are easily circumvented by
53+
// appending another character.
54+
if user.IsUsableUsername(newUsername) != nil {
55+
if len(newUsername) > 39 {
56+
return newUsername[:39] + "2"
57+
}
58+
return newUsername + "2"
59+
}
60+
61+
if len(newUsername) > 40 {
62+
return newUsername[:40]
63+
}
64+
return newUsername
65+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
package blenderid
4+
5+
import "testing"
6+
7+
func Test_gitealizeUsername(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
bidNickname string
11+
want string
12+
}{
13+
{"empty", "", ""},
14+
{"underscore", "_", "_"},
15+
{"reserved-name", "ghost", "ghost2"}, // Reserved name in Gitea.
16+
{"short", "x", "x"},
17+
{"simple", "simple", "simple"},
18+
{"start-bad", "____startbad", "startbad"},
19+
{"end-bad", "endbad___", "endbad"},
20+
{"mid-bad-1", "mid__bad", "mid_bad"},
21+
{"mid-bad-2", "user_.-name", "user_name"},
22+
{"plus-mid-single", "RT2+356", "RT2_356"},
23+
{"plus-mid-many", "RT2+++356", "RT2_356"},
24+
{"plus-end", "RT2356+", "RT2356"},
25+
{
26+
"too-long", // # Max username length is 40:
27+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
28+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
29+
},
30+
{"accented-latin", "Ümlaut-Đenja", "Umlaut-Denja"},
31+
{"thai", "แบบไทย", "aebbaithy"},
32+
{"mandarin", "普通话", "Pu_Tong_Hua"},
33+
{"cyrillic", "ћирилица", "tshirilitsa"},
34+
{"all-bad", "------", "------"},
35+
}
36+
for _, tt := range tests {
37+
t.Run(tt.name, func(t *testing.T) {
38+
if got := gitealizeUsername(tt.bidNickname); got != tt.want {
39+
t.Errorf("gitealizeUsername() = %v, want %v", got, tt.want)
40+
}
41+
})
42+
}
43+
}

0 commit comments

Comments
 (0)