feat(auth): sync avatar from OpenID providers (#821)

This commit is contained in:
Weijie Zhao
2025-06-16 21:59:31 +08:00
committed by GitHub
parent 94ba911c0b
commit a214d68a44
9 changed files with 214 additions and 72 deletions

View File

@@ -21,10 +21,6 @@ import (
"crypto/tls"
"errors"
"fmt"
"image"
"image/draw"
"image/jpeg"
"image/png"
"strings"
"code.vikunja.io/api/pkg/config"
@@ -34,6 +30,7 @@ import (
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/modules/avatar/upload"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"github.com/go-ldap/ldap/v3"
"xorm.io/xorm"
@@ -181,7 +178,7 @@ func AuthenticateUserInLDAP(s *xorm.Session, username, password string, syncGrou
u.AvatarProvider = "ldap"
// Process the avatar image to ensure 1:1 aspect ratio
processedAvatar, err := cropAvatarTo1x1(raw)
processedAvatar, err := utils.CropAvatarTo1x1(raw)
if err != nil {
log.Debugf("Error processing LDAP avatar: %v", err)
// Continue without avatar if processing fails
@@ -311,63 +308,3 @@ func syncUserGroups(l *ldap.Conn, u *user.User, userdn string) (err error) {
return
}
// cropAvatarTo1x1 crops the avatar image to a 1:1 aspect ratio, centered on the image
func cropAvatarTo1x1(imageData []byte) ([]byte, error) {
if len(imageData) == 0 {
return nil, errors.New("empty image data")
}
// Decode the image
img, format, err := image.Decode(bytes.NewReader(imageData))
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
// Get image dimensions
bounds := img.Bounds()
width := bounds.Dx()
height := bounds.Dy()
// If already square, return original
if width == height {
return imageData, nil
}
// Determine the crop size (use the smaller dimension)
size := width
if height < width {
size = height
}
// Calculate crop coordinates to center the image
x0 := (width - size) / 2
y0 := (height - size) / 2
x1 := x0 + size
y1 := y0 + size
// Create the cropping rectangle
cropRect := image.Rect(x0, y0, x1, y1)
// Create a new RGBA image
croppedImg := image.NewRGBA(image.Rect(0, 0, size, size))
// Copy the cropped portion
draw.Draw(croppedImg, croppedImg.Bounds(), img, cropRect.Min, draw.Src)
// Encode the result
var buf bytes.Buffer
switch format {
case "jpeg":
err = jpeg.Encode(&buf, croppedImg, nil)
default:
// Default to PNG if format is unknown
err = png.Encode(&buf, croppedImg)
}
if err != nil {
return nil, fmt.Errorf("failed to encode cropped image: %w", err)
}
return buf.Bytes(), nil
}

View File

@@ -17,9 +17,11 @@
package openid
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
@@ -28,7 +30,9 @@ import (
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/modules/avatar/upload"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/api/pkg/web/handler"
"github.com/coreos/go-oidc/v3/oidc"
@@ -68,6 +72,7 @@ type claims struct {
PreferredUsername string `json:"preferred_username"`
Nickname string `json:"nickname"`
VikunjaGroups []map[string]interface{} `json:"vikunja_groups"`
Picture string `json:"picture"`
}
func init() {
@@ -215,6 +220,39 @@ func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (
return teamData
}
// Download and store a user's avatar from an OpenID provider
func syncUserAvatarFromOpenID(s *xorm.Session, u *user.User, pictureURL string) (err error) {
// Don't sync avatar if no picture URL is provided
if pictureURL == "" {
return fmt.Errorf("no picture URL provided")
}
log.Debugf("Found avatar URL for user %s: %s", u.Username, pictureURL)
// Download avatar
avatarData, err := utils.DownloadImage(pictureURL)
if err != nil {
return fmt.Errorf("error downloading avatar: %w", err)
}
// Process avatar, ensure 1:1 ratio
processedAvatar, err := utils.CropAvatarTo1x1(avatarData)
if err != nil {
return fmt.Errorf("error processing avatar: %w", err)
}
// Set avatar provider to openid
u.AvatarProvider = "openid"
// Store avatar and update user
err = upload.StoreAvatarFile(s, u, bytes.NewReader(processedAvatar))
if err != nil {
return fmt.Errorf("error storing avatar: %w", err)
}
return nil
}
func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *oidc.IDToken) (u *user.User, err error) {
// set defaults
@@ -270,7 +308,10 @@ func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *o
Issuer: idToken.Issuer,
Subject: idToken.Subject,
}
return auth.CreateUserWithRandomUsername(s, uu)
u, err = auth.CreateUserWithRandomUsername(s, uu)
if err != nil {
return nil, err
}
} else if alreadyCreatedFromIssuer {
// try updating user.Name and/or user.Email if necessary
@@ -286,7 +327,13 @@ func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *o
}
}
return
// Try sync avatar if available
err = syncUserAvatarFromOpenID(s, u, cl.Picture)
if err != nil {
log.Errorf("Error syncing avatar for user %s: %v", u.Username, err)
}
return u, nil
}
// mergeClaims combines claims from token and userinfo based on the ForceUserInfo setting
@@ -308,6 +355,10 @@ func mergeClaims(cl *claims, cl2 *claims, forceUserInfo bool) error {
cl.PreferredUsername = cl2.Nickname
}
if (forceUserInfo && cl2.Picture != "") || cl.Picture == "" {
cl.Picture = cl2.Picture
}
if cl.Email == "" {
return &user.ErrNoOpenIDEmailProvided{}
}
@@ -324,7 +375,7 @@ func getClaims(provider *Provider, oauth2Token *oauth2.Token, idToken *oidc.IDTo
return nil, err
}
if provider.ForceUserInfo || cl.Email == "" || cl.Name == "" || cl.PreferredUsername == "" {
if provider.ForceUserInfo || cl.Email == "" || cl.Name == "" || cl.PreferredUsername == "" || cl.Picture == "" {
info, err := provider.openIDProvider.UserInfo(context.Background(), provider.Oauth2Config.TokenSource(context.Background(), oauth2Token))
if err != nil {
log.Errorf("Error getting userinfo for provider %s: %v", provider.Name, err)

View File

@@ -0,0 +1,30 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package openid
import (
"code.vikunja.io/api/pkg/modules/avatar/upload"
"code.vikunja.io/api/pkg/user"
)
type Provider struct{}
func (p *Provider) GetAvatar(user *user.User, size int64) (avatar []byte, mimeType string, err error) {
up := upload.Provider{}
return up.GetAvatar(user, size)
}